From efec9ad254fbd45a222640bc30d8d304b44ef40b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 12:26:23 +0000 Subject: [PATCH 1/5] Encode Effect DateTime values to Firestore Timestamps Decoded models expose timestamp fields as Effect DateTime values, but firestoreEncode did not recognize them, so passing e.g. post.createdAt as a query cursor (Query.startAfter) silently encoded it as a plain object and matched nothing. Both the client and admin converters now encode DateTime to a native Timestamp. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UNPbrv3WeXDeWmTkjKmmL6 --- .../admin/src/lib/firestore/converter.spec.ts | 12 ++++++++++ packages/admin/src/lib/firestore/converter.ts | 7 ++++++ .../src/lib/firestore/converter.spec.ts | 23 +++++++++++++++++++ .../client/src/lib/firestore/converter.ts | 7 ++++++ 4 files changed, 49 insertions(+) diff --git a/packages/admin/src/lib/firestore/converter.spec.ts b/packages/admin/src/lib/firestore/converter.spec.ts index 6c6cb21..0be9642 100644 --- a/packages/admin/src/lib/firestore/converter.spec.ts +++ b/packages/admin/src/lib/firestore/converter.spec.ts @@ -1,3 +1,4 @@ +import { DateTime } from 'effect'; import { describe, expect, it } from 'vitest'; import { FieldValue, @@ -82,6 +83,17 @@ describe('Firestore Converter', () => { expect((result as AdminTimestamp).nanoseconds).toBe(123000000); }); + it('should convert Effect DateTime to Firestore Timestamp', () => { + const fakeFirestore = {} as unknown as Firestore; + const result = firestoreEncode( + fakeFirestore, + DateTime.makeUnsafe(1705315800123), + ); + + expect(result).toBeInstanceOf(AdminTimestamp); + expect((result as AdminTimestamp).toMillis()).toBe(1705315800123); + }); + it('should convert FirestoreSchema.GeoPoint to Firestore GeoPoint', () => { const fakeFirestore = {} as unknown as Firestore; const result = firestoreEncode( diff --git a/packages/admin/src/lib/firestore/converter.ts b/packages/admin/src/lib/firestore/converter.ts index bc8b9f2..6eea3a6 100644 --- a/packages/admin/src/lib/firestore/converter.ts +++ b/packages/admin/src/lib/firestore/converter.ts @@ -7,6 +7,7 @@ import { GeoPoint, Timestamp, } from 'firebase-admin/firestore'; +import { DateTime } from 'effect'; import { FirestoreSchema, Firestore } from 'effect-firebase'; /** @@ -32,6 +33,12 @@ export const firestoreEncode = ( if (data instanceof FirestoreSchema.Timestamp) { return Timestamp.fromMillis(data.toMillis()); } + // Decoded models expose timestamps as Effect DateTime values; without this + // they would fall through to the plain-object branch and encode to garbage + // (notably when used as query cursor values). + if (DateTime.isDateTime(data)) { + return Timestamp.fromMillis(DateTime.toEpochMillis(data)); + } if (data instanceof FirestoreSchema.GeoPoint) { return new GeoPoint(data.latitude, data.longitude); } diff --git a/packages/client/src/lib/firestore/converter.spec.ts b/packages/client/src/lib/firestore/converter.spec.ts index 47adb6f..86cdb40 100644 --- a/packages/client/src/lib/firestore/converter.spec.ts +++ b/packages/client/src/lib/firestore/converter.spec.ts @@ -1,3 +1,4 @@ +import { DateTime } from 'effect'; import { describe, expect, it } from 'vitest'; import { arrayRemove, @@ -87,6 +88,28 @@ describe('Firestore Converter', () => { expect((result as FirebaseTimestamp).nanoseconds).toBe(123000000); }); + it('should convert Effect DateTime to Firestore Timestamp', () => { + const result = firestoreEncode( + fakeFirestore, + DateTime.makeUnsafe(1705315800123), + ); + + expect(result).toBeInstanceOf(FirebaseTimestamp); + expect((result as FirebaseTimestamp).toMillis()).toBe(1705315800123); + }); + + it('should convert Effect DateTime nested in objects and arrays', () => { + const result = firestoreEncode(fakeFirestore, { + createdAt: DateTime.makeUnsafe(1705315800123), + history: [DateTime.makeUnsafe(1705315800000)], + }) as Record; + + expect(result.createdAt).toBeInstanceOf(FirebaseTimestamp); + expect((result.history as unknown[])[0]).toBeInstanceOf( + FirebaseTimestamp, + ); + }); + it('should convert FirestoreSchema.GeoPoint to Firestore GeoPoint', () => { const result = firestoreEncode( fakeFirestore, diff --git a/packages/client/src/lib/firestore/converter.ts b/packages/client/src/lib/firestore/converter.ts index 21c3729..a7282c1 100644 --- a/packages/client/src/lib/firestore/converter.ts +++ b/packages/client/src/lib/firestore/converter.ts @@ -12,6 +12,7 @@ import { serverTimestamp, Timestamp, } from 'firebase/firestore'; +import { DateTime } from 'effect'; import { FirestoreSchema, Firestore } from 'effect-firebase'; /** @@ -37,6 +38,12 @@ export const firestoreEncode = ( if (data instanceof FirestoreSchema.Timestamp) { return Timestamp.fromMillis(data.toMillis()); } + // Decoded models expose timestamps as Effect DateTime values; without this + // they would fall through to the plain-object branch and encode to garbage + // (notably when used as query cursor values). + if (DateTime.isDateTime(data)) { + return Timestamp.fromMillis(DateTime.toEpochMillis(data)); + } if (data instanceof FirestoreSchema.GeoPoint) { return new GeoPoint(data.latitude, data.longitude); } From e2b47b1aa373b989fb760abb3ca1e26ccdcf1ee7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 12:28:14 +0000 Subject: [PATCH 2/5] Add Query.orderByDocumentId for cursor tiebreaking Adds orderByDocumentId/addOrderByDocumentId constructors emitting the __name__ sentinel field path (accepted by both the client and admin SDKs). Ordering by document ID as a secondary key lets startAfter take the last document's ID as a second cursor value, so pages never skip or repeat documents when the primary order field has duplicate values. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UNPbrv3WeXDeWmTkjKmmL6 --- .../src/lib/firestore/query/query.spec.ts | 48 +++++++++++++++++++ .../src/lib/firestore/query/query.ts | 40 ++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 packages/effect-firebase/src/lib/firestore/query/query.spec.ts diff --git a/packages/effect-firebase/src/lib/firestore/query/query.spec.ts b/packages/effect-firebase/src/lib/firestore/query/query.spec.ts new file mode 100644 index 0000000..9b4d269 --- /dev/null +++ b/packages/effect-firebase/src/lib/firestore/query/query.spec.ts @@ -0,0 +1,48 @@ +import { pipe } from 'effect'; +import { describe, expect, it } from 'vitest'; +import { Limit, OrderBy, StartAfter } from './constraints.js'; +import * as Query from './query.js'; + +describe('Query', () => { + describe('orderByDocumentId', () => { + it('emits an OrderBy on the __name__ sentinel field path', () => { + const [constraint] = Query.orderByDocumentId(); + + expect(constraint).toBeInstanceOf(OrderBy); + expect(constraint).toMatchObject({ + field: Query.documentIdFieldPath, + direction: 'asc', + }); + }); + + it('supports descending direction', () => { + const [constraint] = Query.orderByDocumentId('desc'); + + expect(constraint).toMatchObject({ + field: '__name__', + direction: 'desc', + }); + }); + }); + + describe('addOrderByDocumentId', () => { + it('appends after existing constraints for cursor tiebreaking', () => { + const query = pipe( + Query.orderBy('createdAt', 'desc'), + Query.addOrderByDocumentId('desc'), + Query.addStartAfter('ts-value', 'doc-id'), + Query.addLimit(10), + ); + + expect(query).toHaveLength(4); + expect(query[0]).toMatchObject({ field: 'createdAt' }); + expect(query[1]).toMatchObject({ + field: '__name__', + direction: 'desc', + }); + expect(query[2]).toBeInstanceOf(StartAfter); + expect(query[2]).toMatchObject({ values: ['ts-value', 'doc-id'] }); + expect(query[3]).toBeInstanceOf(Limit); + }); + }); +}); diff --git a/packages/effect-firebase/src/lib/firestore/query/query.ts b/packages/effect-firebase/src/lib/firestore/query/query.ts index ea25162..be81e29 100644 --- a/packages/effect-firebase/src/lib/firestore/query/query.ts +++ b/packages/effect-firebase/src/lib/firestore/query/query.ts @@ -89,6 +89,35 @@ export const orderBy = = string & FieldKeys>( direction: OrderByDirection = 'asc', ): Query => [new OrderBy({ field, direction })] as Query; +/** + * The sentinel field path that orders by document ID, understood by both the + * client and admin SDKs (equivalent to `FieldPath.documentId()`). + */ +export const documentIdFieldPath = '__name__'; + +/** + * Create an orderBy constraint on the document ID. + * + * Useful as a cursor tiebreaker: when paginating with `startAfter` on a field + * that can have duplicate values (e.g. timestamps), add a document ID ordering + * and pass the last document's ID as a second cursor value so pages never + * skip or repeat documents. + * + * @example + * ```ts + * pipe( + * Query.orderBy('createdAt', 'desc'), + * Query.addOrderByDocumentId(), + * Query.addStartAfter(lastTimestamp, lastDocId), + * Query.addLimit(10), + * ) + * ``` + */ +export const orderByDocumentId = ( + direction: OrderByDirection = 'asc', +): Query => + [new OrderBy({ field: documentIdFieldPath, direction })] as Query; + /** * Create a limit constraint. * @@ -243,6 +272,17 @@ export const addOrderBy = (query: Query): Query => [...query, new OrderBy({ field, direction })] as Query; +/** + * Pipeable version of orderByDocumentId that appends to an existing query. + */ +export const addOrderByDocumentId = + (direction: OrderByDirection = 'asc') => + (query: Query): Query => + [ + ...query, + new OrderBy({ field: documentIdFieldPath, direction }), + ] as Query; + /** * Pipeable version of limit that appends to an existing query. * From 17bf4e7955b3ce89450db5522234fd8b8560b8f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 12:32:17 +0000 Subject: [PATCH 3/5] Add realtime paginated query atom helper to example app makePaginatedQueryAtom packages the growing-limit pagination pattern used by FlutterFire UI's FirestoreQueryBuilder as a writable atom: reading yields { items, hasMore, isFetchingMore }, writing grows the window by one page. It subscribes with limit(pages * pageSize + 1) so hasMore is exact (the probe row is never surfaced), and the whole window is a single live listener, so the list stays realtime without cursor stitching. The Firestore CRUD example's post list now paginates with a Load more button, with tests covering the window growth and hasMore behavior. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UNPbrv3WeXDeWmTkjKmmL6 --- example/app/src/__tests__/firestore.test.tsx | 67 +++++++++++++++- example/app/src/lib/atoms.ts | 23 +++++- example/app/src/lib/pagination.ts | 80 ++++++++++++++++++++ example/app/src/routes/firestore.tsx | 18 ++++- 4 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 example/app/src/lib/pagination.ts diff --git a/example/app/src/__tests__/firestore.test.tsx b/example/app/src/__tests__/firestore.test.tsx index 84ef31a..5037e9e 100644 --- a/example/app/src/__tests__/firestore.test.tsx +++ b/example/app/src/__tests__/firestore.test.tsx @@ -1,11 +1,41 @@ -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { Stream } from 'effect'; import { MockFirestoreService } from '@effect-firebase/mock'; +import { FirestoreSchema } from 'effect-firebase'; +import type { Snapshot } from 'effect-firebase'; import { RegistryProvider } from '@effect/atom-react'; import { describe, it, expect } from 'vitest'; import { firestoreLayerAtom } from '../lib/atoms.js'; import { PostList } from '../routes/firestore.js'; +const makeSnapshot = (i: number): Snapshot => [ + { id: `post-${i}`, path: `posts/post-${i}` }, + { + title: `Post ${i}`, + content: `Content ${i}`, + author: FirestoreSchema.Reference.makeFromPath('authors/1'), + createdAt: FirestoreSchema.Timestamp.fromMillis(1700000000000 - i * 1000), + updatedAt: FirestoreSchema.Timestamp.fromMillis(1700000000000 - i * 1000), + checked: false, + list: [], + }, +]; + +/** Mock layer whose streamQuery honors the query's Limit constraint. */ +const layerWithPosts = (count: number) => { + const snapshots = Array.from({ length: count }, (_, i) => makeSnapshot(i)); + return MockFirestoreService({ + streamQuery: (_path, constraints) => { + const limit = constraints.find( + (c): c is Extract => c._tag === 'Limit', + )?.count; + return Stream.make( + limit === undefined ? snapshots : snapshots.slice(0, limit), + ); + }, + }); +}; + describe('PostList', () => { it('renders the empty state when the mock layer yields no posts', async () => { const layer = MockFirestoreService({ @@ -20,4 +50,39 @@ describe('PostList', () => { expect(await screen.findByText(/No posts found/i)).toBeTruthy(); }); + + it('paginates: shows one page plus a Load more button that grows the window', async () => { + render( + + undefined} /> + , + ); + + // First window: pageSize (5) items, the extra probe row is not rendered. + expect(await screen.findByText('Post 0')).toBeTruthy(); + expect(screen.getAllByText(/^Post \d+$/)).toHaveLength(5); + + fireEvent.click(screen.getByRole('button', { name: /Load more/i })); + + // Second window: all 7 items, and no further page exists. + expect(await screen.findByText('Post 6')).toBeTruthy(); + expect(screen.getAllByText(/^Post \d+$/)).toHaveLength(7); + expect(screen.queryByRole('button', { name: /Load more/i })).toBeNull(); + }); + + it('hides Load more when the collection fits within one page', async () => { + render( + + undefined} /> + , + ); + + expect(await screen.findByText('Post 0')).toBeTruthy(); + expect(screen.getAllByText(/^Post \d+$/)).toHaveLength(5); + expect(screen.queryByRole('button', { name: /Load more/i })).toBeNull(); + }); }); diff --git a/example/app/src/lib/atoms.ts b/example/app/src/lib/atoms.ts index ce54cbd..b86359c 100644 --- a/example/app/src/lib/atoms.ts +++ b/example/app/src/lib/atoms.ts @@ -1,7 +1,8 @@ -import { Effect, Layer, Stream } from 'effect'; +import { Effect, Layer, Stream, pipe } from 'effect'; import { Atom } from 'effect/unstable/reactivity'; -import { FirestoreService } from 'effect-firebase'; +import { FirestoreService, Query } from 'effect-firebase'; import { PostId, PostModel, PostRepository } from '@example/shared'; +import { makePaginatedQueryAtom } from './pagination.js'; /** * Indirection that makes the Firestore layer swappable at the registry level. @@ -64,6 +65,24 @@ export const latestPostsAtom = clientRuntime.atom( Stream.unwrap(Effect.map(PostRepository, (r) => r.latestPosts())), ); +// Realtime paginated feed (growing-limit pattern, REACT.md §Pagination). +// Reading yields { items, hasMore, isFetchingMore }; writing (any value) +// grows the window by one page. The whole window is one live listener. +export const paginatedPostsAtom = makePaginatedQueryAtom(clientRuntime, { + pageSize: 5, + stream: (limit) => + Stream.unwrap( + Effect.map(PostRepository, (r) => + r.queryStream( + pipe( + Query.orderBy('createdAt', 'desc'), + Query.addLimit(limit), + ), + ), + ), + ), +}); + // Mutations — writable atoms exposing AsyncResult state and a setter. // `concurrent: true` lets invocations overlap; the default interrupts the // in-flight previous call (latest-wins), which can drop a write when two diff --git a/example/app/src/lib/pagination.ts b/example/app/src/lib/pagination.ts new file mode 100644 index 0000000..e101e03 --- /dev/null +++ b/example/app/src/lib/pagination.ts @@ -0,0 +1,80 @@ +import type { Stream } from 'effect'; +import { AsyncResult, Atom } from 'effect/unstable/reactivity'; + +/** + * The value exposed by a paginated query atom. + */ +export interface Paginated { + readonly items: ReadonlyArray; + /** Whether another page exists beyond the currently loaded items. */ + readonly hasMore: boolean; + /** Whether a fetchMore is in flight (initial loads report `false`). */ + readonly isFetchingMore: boolean; +} + +/** + * Build a realtime, growing-limit paginated atom from a stream-returning + * query — the pattern used by FlutterFire UI's `FirestoreQueryBuilder`. + * + * The atom subscribes to `stream(pages * pageSize + 1)`: one extra row is + * fetched beyond the visible window so `hasMore` is exact, and never + * surfaced in `items`. Writing to the atom (any value) is `fetchMore`: it + * grows the window by one page, ignored while a fetch is in flight or when + * no more rows exist. Because the whole window is a single Firestore + * listener, the entire list stays live — no cursor stitching, no gaps or + * duplicates when documents shift between pages. + * + * @example + * ```ts + * const postsPaginatedAtom = makePaginatedQueryAtom(clientRuntime, { + * pageSize: 10, + * stream: (limit) => + * Stream.unwrap(Effect.map(PostRepository, (r) => + * r.queryStream(pipe( + * Query.orderBy('createdAt', 'desc'), + * Query.addLimit(limit), + * )), + * )), + * }); + * + * // const paginated = useAtomValue(postsPaginatedAtom) + * // const fetchMore = useAtomSet(postsPaginatedAtom) + * ``` + */ +export const makePaginatedQueryAtom = ( + runtime: Atom.AtomRuntime, + options: { + readonly pageSize: number; + readonly stream: ( + limit: number, + ) => Stream.Stream, E, R | Atom.AtomRegistry>; + }, +) => { + const pageCountAtom = Atom.make(1); + + const rowsAtom = runtime.atom((get) => + options.stream(get(pageCountAtom) * options.pageSize + 1), + ); + + return Atom.writable( + (get) => { + const visible = get(pageCountAtom) * options.pageSize; + const rows = get(rowsAtom); + // When the window grows, the rows atom rebuilds but keeps its previous + // success with `waiting: true` — that window is exactly isFetchingMore. + const isFetchingMore = AsyncResult.isSuccess(rows) && rows.waiting; + return AsyncResult.map(rows, (all) => ({ + items: all.slice(0, visible), + hasMore: all.length > visible, + isFetchingMore, + })); + }, + (ctx, _value: void) => { + const rows = ctx.get(rowsAtom); + if (!AsyncResult.isSuccess(rows) || rows.waiting) return; + const pages = ctx.get(pageCountAtom); + if (rows.value.length <= pages * options.pageSize) return; + ctx.set(pageCountAtom, pages + 1); + }, + ); +}; diff --git a/example/app/src/routes/firestore.tsx b/example/app/src/routes/firestore.tsx index 0b4cf28..c217920 100644 --- a/example/app/src/routes/firestore.tsx +++ b/example/app/src/routes/firestore.tsx @@ -16,7 +16,7 @@ import { TextArea, } from '../components/core'; import { - latestPostsAtom, + paginatedPostsAtom, addPostAtom, updatePostAtom, deletePostAtom, @@ -180,7 +180,8 @@ function PostForm({ } export function PostList({ onEdit }: { onEdit: (post: Post) => void }) { - const result = useAtomValue(latestPostsAtom); + const result = useAtomValue(paginatedPostsAtom); + const fetchMore = useAtomSet(paginatedPostsAtom); const remove = useAtomSet(deletePostAtom, { mode: 'promise' }); const [deleteError, setDeleteError] = useState(null); @@ -200,7 +201,7 @@ export function PostList({ onEdit }: { onEdit: (post: Post) => void }) { .onFailure((cause) => ( )) - .onSuccess((posts) => + .onSuccess(({ items: posts, hasMore, isFetchingMore }) => posts.length === 0 ? ( ) : ( @@ -252,6 +253,17 @@ export function PostList({ onEdit }: { onEdit: (post: Post) => void }) { ))} + {hasMore && ( +
+ +
+ )} ), ) From 1c52c2ceca6c185e4e84acede02581e973b78775 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 12:33:48 +0000 Subject: [PATCH 4/5] Document pagination recipes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Pagination section to REACT.md covering the two supported patterns — realtime infinite loading via the growing-limit helper, and prev/next paging via a cursor stack of one-shot page atoms — plus guidance on cursor encoding, document ID tiebreaking, and exact hasMore detection. Also shows the tiebreaker query in the package README. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UNPbrv3WeXDeWmTkjKmmL6 --- REACT.md | 150 +++++++++++++++++++++++++++-- packages/effect-firebase/README.md | 11 ++- 2 files changed, 154 insertions(+), 7 deletions(-) diff --git a/REACT.md b/REACT.md index 97a44aa..51a6c34 100644 --- a/REACT.md +++ b/REACT.md @@ -18,9 +18,10 @@ released against. 2. [Repository atoms](#2-repository-atoms) 3. [Reading data](#3-reading-data) 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) +5. [Pagination](#5-pagination) +6. [Forms with validation](#6-forms-with-validation) +7. [Testing with a mock layer](#7-testing-with-a-mock-layer) +8. [Caveats](#8-caveats) --- @@ -242,7 +243,144 @@ function CreatePost() { If you also need the `AsyncResult` state (loading / success / error) for UI, use `useAtom(atom)` to get both `[result, set]`. -## 5. Forms with validation +## 5. Pagination + +Firestore paginates by **cursor**, not by offset — there is no "jump to page +7". Two patterns cover the real use cases, and they mirror what the official +FirebaseUI libraries do (FlutterFire UI uses the first, FirebaseUI-Android's +Paging 3 adapter the second): + +### 5a. Realtime infinite loading — growing limit + +For a live feed with "load more" / infinite scroll, don't stitch cursor pages +together — a single snapshot listener whose `limit` grows by one page at a +time is simpler and immune to documents shifting between page boundaries. +`makePaginatedQueryAtom` +([`example/app/src/lib/pagination.ts`](./example/app/src/lib/pagination.ts)) +packages the state machine: + +```ts +// example/app/src/lib/atoms.ts +export const paginatedPostsAtom = makePaginatedQueryAtom(clientRuntime, { + pageSize: 5, + stream: (limit) => + Stream.unwrap( + Effect.map(PostRepository, (r) => + r.queryStream( + pipe( + Query.orderBy('createdAt', 'desc'), + Query.addLimit(limit), + ), + ), + ), + ), +}); +``` + +```tsx +function PostList() { + const result = useAtomValue(paginatedPostsAtom); + const fetchMore = useAtomSet(paginatedPostsAtom); + + return AsyncResult.builder(result) + .onInitial(() => ) + .onFailure((cause) => ) + .onSuccess(({ items, hasMore, isFetchingMore }) => ( + <> + {items.map((p) => ( + + ))} + {hasMore && ( + + )} + + )) + .exhaustive(); +} +``` + +For infinite scroll, swap the button for an `IntersectionObserver` sentinel +that calls `fetchMore()` when it becomes visible; repeated calls while a +fetch is in flight (or when `hasMore` is false) are no-ops. + +How it works: the atom subscribes with `limit(pages * pageSize + 1)` — one +probe row beyond the visible window, never surfaced in `items`, so +`hasMore = rows.length > visible` is exact. Each `fetchMore` re-subscribes +with a larger limit; already-synced documents are served from Firestore's +local cache, and the whole window stays live through a single listener. + +### 5b. Prev/next page buttons — cursor stack + +For one-shot page-at-a-time UIs, use `Query.startAfter` with the last row's +order-field value as the cursor, and keep a **stack** of cursors: push to go +forward, pop to go back. Define an `Atom.family` keyed by the cursor +(serialize to epoch millis — families key by `Equal` equality, so prefer a +primitive): + +```ts +export const postsPageAtom = Atom.family((cursorMillis: number | null) => + clientRuntime + .atom( + Effect.gen(function* () { + const repo = yield* PostRepository; + return yield* repo.query( + pipe( + Query.orderBy('createdAt', 'desc'), + cursorMillis === null + ? (q: Query.Query) => q + : Query.addStartAfter( + FirestoreSchema.Timestamp.fromMillis(cursorMillis), + ), + Query.addLimit(PAGE_SIZE), + ), + ); + }), + ) + .pipe(Atom.withReactivity(['posts'])), +); +``` + +```tsx +function PaginatedPosts() { + // cursors[i] opens page i; null opens the first page + const [cursors, setCursors] = useState>([null]); + const result = useAtomValue(postsPageAtom(cursors[cursors.length - 1])); + // render items, then: + // Previous: disabled={cursors.length === 1} + // onClick={() => setCursors((c) => c.slice(0, -1))} + // Next: disabled={posts.length < PAGE_SIZE} + // onClick={() => setCursors((c) => [ + // ...c, DateTime.toEpochMillis(posts[posts.length - 1].createdAt), + // ])} +} +``` + +These are one-shot reads, so pair `Atom.withReactivity(['posts'])` with +`reactivityKeys: ['posts']` on mutations to refresh mounted pages. Add +`Atom.setIdleTTL` to the family if back-navigation should render instantly +from the still-warm previous page. + +### Pagination notes + +- **Cursor values are encoded like document data.** Decoded models expose + timestamps as Effect `DateTime` values and the converters encode them to + native `Timestamp`s, so `Query.addStartAfter(post.createdAt)` works — as + do `FirestoreSchema.Timestamp` values and plain strings/numbers. +- **Break ties on the order field.** If the field can hold duplicate values, + a single-value cursor can skip or repeat rows across a page boundary. Add + `Query.addOrderByDocumentId()` after the primary `orderBy` and pass the + last document's ID as a second cursor value: + `Query.addStartAfter(lastCreatedAt, lastDocId)`. Server-generated + timestamps rarely collide; ratings, counts, and user-entered dates do. +- **`hasMore`:** fetch one row beyond the page (`limit(pageSize + 1)`) and + slice it off for an exact answer — the growing-limit helper does this + internally. `posts.length < PAGE_SIZE` is a cheaper heuristic that shows + one dead "Next" click when the collection size is an exact multiple of the + page size. + +## 6. Forms with validation `effect/Schema` implements [Standard Schema v1](https://github.com/standard-schema/standard-schema), and @@ -291,7 +429,7 @@ See [`example/app/src/routes/firestore.tsx`](./example/app/src/routes/firestore. for the full form including edit mode (re-key the form on the editing id to load fresh defaults). -## 6. Testing with a mock layer +## 7. Testing with a mock layer `@effect-firebase/mock` exports `MockFirestoreService(overrides)`, which returns a `Layer` whose methods throw by default but accept @@ -324,7 +462,7 @@ 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 +## 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/packages/effect-firebase/README.md b/packages/effect-firebase/README.md index 5572176..e2320d7 100644 --- a/packages/effect-firebase/README.md +++ b/packages/effect-firebase/README.md @@ -87,7 +87,16 @@ import { Query } from 'effect-firebase'; Query.where('status', '==', 'published'); Query.orderBy('createdAt', 'desc'); Query.limit(20); -Query.startAfter(lastDoc); +Query.startAfter(lastCreatedAt); + +// Cursor pagination with a document ID tiebreaker, so pages never skip +// or repeat documents when the order field has duplicate values +pipe( + Query.orderBy('createdAt', 'desc'), + Query.addOrderByDocumentId('desc'), + Query.addStartAfter(lastCreatedAt, lastDocId), + Query.addLimit(20), +); // Combine Query.and( From 3eeaee2b3d5b9fd07ad4879752cb1d0752884969 Mon Sep 17 00:00:00 2001 From: Frederik Wallner Date: Wed, 26 Aug 2026 14:55:48 +0000 Subject: [PATCH 5/5] Address review findings on pagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mock: resolve the __name__ sentinel (Query.orderByDocumentId) to the snapshot's document ID in ordering, cursor comparison, and the missing-field exclusion — previously every document was filtered out because __name__ never exists in document data. - Pagination helper: reject non-positive or non-integer pageSize. - REACT.md: the cursor-stack recipe now includes the document ID tiebreaker (orderByDocumentId + two-value startAfter cursor). - README: add the missing pipe import to the query example. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UNPbrv3WeXDeWmTkjKmmL6 --- REACT.md | 34 +++++++++++++------ example/app/src/lib/pagination.ts | 4 +++ packages/effect-firebase/README.md | 1 + .../src/lib/firestore/query-filter.spec.ts | 17 ++++++++++ .../mock/src/lib/firestore/query-filter.ts | 13 +++++-- 5 files changed, 56 insertions(+), 13 deletions(-) diff --git a/REACT.md b/REACT.md index f001aaf..1212fc9 100644 --- a/REACT.md +++ b/REACT.md @@ -317,24 +317,34 @@ local cache, and the whole window stays live through a single listener. ### 5b. Prev/next page buttons — cursor stack For one-shot page-at-a-time UIs, use `Query.startAfter` with the last row's -order-field value as the cursor, and keep a **stack** of cursors: push to go -forward, pop to go back. Define an `Atom.family` keyed by the cursor -(serialize to epoch millis — families key by `Equal` equality, so prefer a -primitive): +order-field value **plus its document ID** as the cursor (the ID tiebreaker +keeps pages exact when several rows share the same `createdAt`), and keep a +**stack** of cursors: push to go forward, pop to go back. Define an +`Atom.family` keyed by the cursor, serialized to a string — families key by +`Equal` equality, so prefer a primitive: ```ts -export const postsPageAtom = Atom.family((cursorMillis: number | null) => +/** ":" of the previous page's last row; null = first page. */ +type PageCursor = string | null; + +const cursorFor = (post: typeof PostModel.Type): PageCursor => + `${DateTime.toEpochMillis(post.createdAt)}:${post.id}`; + +export const postsPageAtom = Atom.family((cursor: PageCursor) => clientRuntime .atom( Effect.gen(function* () { const repo = yield* PostRepository; + const [millis, id] = cursor === null ? [] : cursor.split(/:(.*)/s); return yield* repo.query( pipe( Query.orderBy('createdAt', 'desc'), - cursorMillis === null + Query.addOrderByDocumentId('desc'), + cursor === null ? (q: Query.Query) => q : Query.addStartAfter( - FirestoreSchema.Timestamp.fromMillis(cursorMillis), + FirestoreSchema.Timestamp.fromMillis(Number(millis)), + id, ), Query.addLimit(PAGE_SIZE), ), @@ -348,14 +358,14 @@ export const postsPageAtom = Atom.family((cursorMillis: number | null) => ```tsx function PaginatedPosts() { // cursors[i] opens page i; null opens the first page - const [cursors, setCursors] = useState>([null]); + const [cursors, setCursors] = useState>([null]); const result = useAtomValue(postsPageAtom(cursors[cursors.length - 1])); // render items, then: // Previous: disabled={cursors.length === 1} // onClick={() => setCursors((c) => c.slice(0, -1))} // Next: disabled={posts.length < PAGE_SIZE} // onClick={() => setCursors((c) => [ - // ...c, DateTime.toEpochMillis(posts[posts.length - 1].createdAt), + // ...c, cursorFor(posts[posts.length - 1]), // ])} } ``` @@ -372,8 +382,10 @@ from the still-warm previous page. native `Timestamp`s, so `Query.addStartAfter(post.createdAt)` works — as do `FirestoreSchema.Timestamp` values and plain strings/numbers. - **Break ties on the order field.** If the field can hold duplicate values, - a single-value cursor can skip or repeat rows across a page boundary. Add - `Query.addOrderByDocumentId()` after the primary `orderBy` and pass the + a single-value cursor can skip or repeat rows across a page boundary — a + value cursor excludes _every_ row matching the cursor values, not just the + one you paged past. The recipe above guards against this by adding + `Query.addOrderByDocumentId()` after the primary `orderBy` and passing the last document's ID as a second cursor value: `Query.addStartAfter(lastCreatedAt, lastDocId)`. Server-generated timestamps rarely collide; ratings, counts, and user-entered dates do. diff --git a/example/app/src/lib/pagination.ts b/example/app/src/lib/pagination.ts index e101e03..b6f821a 100644 --- a/example/app/src/lib/pagination.ts +++ b/example/app/src/lib/pagination.ts @@ -50,6 +50,10 @@ export const makePaginatedQueryAtom = ( ) => Stream.Stream, E, R | Atom.AtomRegistry>; }, ) => { + if (!Number.isSafeInteger(options.pageSize) || options.pageSize < 1) { + throw new RangeError('pageSize must be a positive integer'); + } + const pageCountAtom = Atom.make(1); const rowsAtom = runtime.atom((get) => diff --git a/packages/effect-firebase/README.md b/packages/effect-firebase/README.md index e2320d7..16fec9f 100644 --- a/packages/effect-firebase/README.md +++ b/packages/effect-firebase/README.md @@ -82,6 +82,7 @@ repo.getOne(...constraints); // Effect ## Queries ```typescript +import { pipe } from 'effect'; import { Query } from 'effect-firebase'; Query.where('status', '==', 'published'); diff --git a/packages/mock/src/lib/firestore/query-filter.spec.ts b/packages/mock/src/lib/firestore/query-filter.spec.ts index c20b392..199bd4b 100644 --- a/packages/mock/src/lib/firestore/query-filter.spec.ts +++ b/packages/mock/src/lib/firestore/query-filter.spec.ts @@ -177,6 +177,23 @@ describe('applyConstraints', () => { ).toEqual(['1', '3', '2']); }); + it('resolves the __name__ sentinel to the document ID', () => { + // No document is excluded (every document has an ID), ordering follows + // the IDs, and __name__ cursor values compare against IDs. + expect( + ids(applyConstraints(posts, Query.orderByDocumentId('desc'))), + ).toEqual(['4', '3', '2', '1']); + expect( + ids( + applyConstraints(posts, [ + new Query.OrderBy({ field: 'status', direction: 'asc' }), + ...Query.orderByDocumentId('asc'), + new Query.StartAfter({ values: ['published', '2'] }), + ]), + ), + ).toEqual(['3']); + }); + 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 ad96637..c50d73a 100644 --- a/packages/mock/src/lib/firestore/query-filter.ts +++ b/packages/mock/src/lib/firestore/query-filter.ts @@ -91,7 +91,13 @@ const orderValues = ( orderBys: ReadonlyArray, ): ReadonlyArray => { const [ref, data] = snapshot; - const values = orderBys.map((orderBy) => fieldValue(data, orderBy.field)); + // The __name__ sentinel (Query.orderByDocumentId) resolves to the + // document ID, which lives on the ref rather than in the data. + const values = orderBys.map((orderBy) => + orderBy.field === Query.documentIdFieldPath + ? ref.id + : fieldValue(data, orderBy.field), + ); // Firestore implicitly orders by document ID as the final tiebreaker. return [...values, ref.id]; }; @@ -178,11 +184,14 @@ export const applyConstraints = ( } // Firestore excludes documents that lack a field named by an orderBy. + // The __name__ sentinel is exempt: every document has an ID. let results = snapshots.filter( ([, data]) => filters.every((filter) => matchesFilter(data, filter)) && orderBys.every( - (orderBy) => fieldValue(data, orderBy.field) !== undefined, + (orderBy) => + orderBy.field === Query.documentIdFieldPath || + fieldValue(data, orderBy.field) !== undefined, ), );