From bce0fd4bd537d6aee4e5f5559b6b656031aff27b Mon Sep 17 00:00:00 2001 From: Tryston Perry Date: Fri, 5 Jun 2026 10:52:10 -0700 Subject: [PATCH] docs: add architecture and agent guidance --- AGENTS.md | 132 +++++++++++++++++++++ CLAUDE.md | 11 ++ CONTRIBUTING.md | 74 ++++++++++++ README.md | 96 ++++++++++++++- docs/api-recipes.md | 277 +++++++++++++++++++++++++++++++++++++++++++ docs/architecture.md | 194 ++++++++++++++++++++++++++++++ 6 files changed, 783 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 CONTRIBUTING.md create mode 100644 docs/api-recipes.md create mode 100644 docs/architecture.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..292ba7c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,132 @@ +# Firestate Agent Guide + +This file is the repo-level operating manual for coding agents. Read it before +changing code. + +## What This Package Is + +Firestate is a TypeScript library for using Cloud Firestore from React with +real-time listeners, optimistic local state, debounced writes, undo/redo, sync +state tracking, and optional Zod validation. + +The recommended application API is registry-based: + +```ts +import { z } from 'zod' +import { defineFirestate, doc, col } from '@hvakr/firestate' + +const TaskListSchema = z.object({ name: z.string(), createdAt: z.number() }) +const TaskSchema = z.object({ + title: z.string(), + completed: z.boolean(), +}) + +export const { useTaskList, useTasks } = defineFirestate({ + taskList: doc({ path: 'taskLists/{listId}', schema: TaskListSchema }), + tasks: col({ path: 'taskLists/{listId}/tasks', schema: TaskSchema }), +}) +``` + +The lower-level API is `defineDocument` / `defineCollection` plus +`useDocument` / `useCollection`. Use it for custom path derivation, non-React +usage, or plain TypeScript shapes without Zod validation. + +## Commands + +Use pnpm. + +```bash +pnpm install +pnpm typecheck +pnpm test +pnpm build +``` + +CI runs `pnpm typecheck`, `pnpm build`, and `pnpm test` on Node 22. + +## Source Map + +- `src/index.ts` - public exports. Update this when adding public API. +- `src/firestate.ts` - registry API: `defineFirestate`, `doc`, `col`, path + template validation, generated hook typing. +- `src/schema.ts` - lower-level definition helpers. +- `src/types.ts` - public state, handle, definition, undo, and config types. +- `src/hooks.ts` - React hooks and `useSyncExternalStore` integration. +- `src/provider.tsx` - React providers and unsaved-changes hook. +- `src/store.ts` - shared Firestore config, undo manager, global sync state, + error reporting. +- `src/document.ts` - single-document subscription, optimistic state, set, + update, delete, sync, conflict rebase. +- `src/collection.ts` - collection subscription, add, update, remove, batched + sync, lazy loading. +- `src/diff.ts` - Firestore-aware diff, flattening, cloning, equality helpers. +- `src/undo.ts` - framework-agnostic undo manager. +- `examples/react-tasks/` - runnable React + Firebase example. + +## Behavioral Contracts + +Preserve these unless the task explicitly changes them. + +- The registry API requires Zod schemas. `doc()` and `col()` infer data types + from `schema` and infer hook params from `{name}` placeholders in `path`. +- `defineDocument` and `defineCollection` keep the plain TypeScript escape + hatch. Their `schema` field is optional. +- Schemas are validation guards only. Firestate calls `schema.parse(...)` on + full writes (`document.set`, `collection.add`) but stores the caller's + original object. Do not store the parsed result unless intentionally changing + this contract. +- Partial `update(diff)` calls are not Zod-validated because diffs may include + Firestore sentinels such as `serverTimestamp()`, `arrayUnion()`, or + `deleteField()`. +- Document `update()` requires existing current data. Use `set()` to create or + replace a document. +- Collection `add`, `update`, and `remove` require the first snapshot. They + bail before the initial snapshot to avoid clobbering unknown server fields. +- `enabled: false` on hooks must not resolve paths or create subscriptions. It + returns stable no-op handles. +- `queryConstraints` should be treated by reference. Callers are expected to + memoize inline arrays. +- `useSyncExternalStore` snapshots and handles must have stable identity between + changes. Do not rebuild snapshots on every `getSnapshot()` call. +- Unmounting a subscription clears its autosave timer and unregisters its sync + state. Pending debounced edits are not automatically flushed on `stop()`. +- Undo actions are client-local. Grouped undo actions undo newest to oldest and + redo oldest to newest. +- Firestore updates use flattened diffs for `updateDoc`; full document + replacement and creation use `setDoc`. + +## Test Guide + +Add or update focused tests near the behavior being changed: + +- Registry typing/path behavior: `src/schema.test.ts` and + `src/firestate.test.ts`. +- Document subscription behavior: `src/firestate.test.ts` and + `src/firestate.integration.test.ts`. +- Collection behavior: `src/firestate.integration.test.ts` and + `src/store.test.ts`. +- Diff behavior: `src/diff.test.ts`. +- Undo behavior: `src/undo.test.ts`. +- Store/global sync behavior: `src/store.test.ts`. + +Before finishing code changes, run at least: + +```bash +pnpm typecheck +pnpm test +``` + +For public API or build output changes, also run: + +```bash +pnpm build +``` + +## Documentation + +- User README: `README.md` +- Architecture notes: `docs/architecture.md` +- Usage recipes: `docs/api-recipes.md` +- Contributor workflow: `CONTRIBUTING.md` + +When changing public behavior, update the README or docs in the same change. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..721ae9b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,11 @@ +# Claude Instructions + +Start with `AGENTS.md`. It is the source of truth for repo structure, +commands, architecture, and behavioral contracts. + +Then use: + +- `README.md` for public usage documentation. +- `docs/architecture.md` for internal design. +- `docs/api-recipes.md` for implementation examples and gotchas. +- `CONTRIBUTING.md` for local development and PR workflow. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6b86903 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,74 @@ +# Contributing + +Thanks for working on Firestate. This repo is a small TypeScript library, so +changes should stay focused and come with tests when behavior changes. + +## Setup + +```bash +pnpm install +``` + +This package targets Node 18+ for consumers. CI runs on Node 22. + +## Development Commands + +```bash +pnpm typecheck +pnpm test +pnpm build +``` + +Useful variants: + +```bash +pnpm test:watch +pnpm test:coverage +``` + +## Project Layout + +- `src/` contains the library source. +- `src/index.ts` is the public export surface. +- `examples/react-tasks/` is a runnable React + Firebase example. +- `docs/architecture.md` explains how the internals fit together. +- `docs/api-recipes.md` contains usage examples and edge-case guidance. + +## Change Guidelines + +- Prefer the registry API (`defineFirestate`, `doc`, `col`) for examples and + app-facing docs. +- Keep the lower-level API (`defineDocument`, `defineCollection`, + `useDocument`, `useCollection`) working as an escape hatch. +- Preserve the Zod contract: schemas validate `set` and `add`, but parsed + values are not written and partial `update` diffs are not validated. +- Keep React snapshots stable for `useSyncExternalStore`. +- Do not add dependencies unless they remove real maintenance burden. +- Update docs when changing public behavior. + +## Testing Expectations + +Run these before opening a PR: + +```bash +pnpm typecheck +pnpm test +pnpm build +``` + +Add focused tests for behavior changes: + +- Diff behavior: `src/diff.test.ts` +- Undo behavior: `src/undo.test.ts` +- Registry and schema behavior: `src/schema.test.ts`, `src/firestate.test.ts` +- Store and sync behavior: `src/store.test.ts` +- Firestore subscription behavior: `src/firestate.integration.test.ts` + +## Releases + +Publishing is handled by the `Publish Package to npm` workflow when a GitHub +release is published. The package build command is: + +```bash +pnpm build +``` diff --git a/README.md b/README.md index 3511885..921af23 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ Both layers share the same store, undo manager, and sync semantics — the regis - [Installation](#installation) - [Quick Start](#quick-start) - [Examples](#examples) +- [Documentation](#documentation) - [Core Concepts](#core-concepts) - [API Reference](#api-reference) - [Diff Utilities](#diff-utilities) @@ -245,6 +246,14 @@ Check out the [examples](./examples) directory for complete, runnable examples: - **[React Tasks](./examples/react-tasks)** - A simple task manager demonstrating documents, collections, undo/redo, sync indicators, and real-time updates. +## Documentation + +- [Architecture](./docs/architecture.md) - how the registry API, hooks, store, subscriptions, diffing, sync, and undo layers fit together. +- [API Recipes](./docs/api-recipes.md) - focused examples for common usage patterns and edge cases. +- [Contributing](./CONTRIBUTING.md) - local setup, commands, tests, and release notes. +- [Agent Guide](./AGENTS.md) - repo map and behavioral contracts for AI coding agents. +- [Claude Instructions](./CLAUDE.md) - short pointer for Claude Code. + ## Core Concepts ### Documents vs Collections @@ -359,6 +368,86 @@ awaiting writes is not feasible. ## API Reference +### Registry API + +#### `defineFirestate(registry)` + +Creates typed React hooks from a registry object. Each key becomes a hook named +`use{CapitalizedKey}`. + +```typescript +import { z } from 'zod' +import { defineFirestate, doc, col } from '@hvakr/firestate' + +const ProjectSchema = z.object({ + name: z.string(), + createdAt: z.number(), +}) + +const SpaceSchema = z.object({ + name: z.string(), + area: z.number(), + floor: z.number(), +}) + +export const { useProject, useSpaces } = defineFirestate({ + project: doc({ + path: 'projects/{projectId}', + schema: ProjectSchema, + }), + spaces: col({ + path: 'projects/{projectId}/spaces', + schema: SpaceSchema, + lazy: true, + }), +}) +``` + +Generated hooks require the params implied by the path template: + +```tsx +const project = useProject({ projectId }) +const spaces = useSpaces({ projectId }) +``` + +Use the second argument for hook options such as `enabled`, `readOnly`, +`undoable`, or collection `queryConstraints`: + +```tsx +const spaces = useSpaces( + { projectId }, + { + enabled: Boolean(projectId), + queryConstraints, + } +) +``` + +#### `doc(options)` and `col(options)` + +Declare registry entries. A Zod `schema` is required and drives both the +generated TypeScript data type and runtime validation for full writes. + +```typescript +doc({ + path: 'projects/{projectId}', + schema: ProjectSchema, + autosave: 1000, + readOnly: false, + retryOnError: false, +}) + +col({ + path: 'projects/{projectId}/spaces', + schema: SpaceSchema, + lazy: true, + queryConstraints: [], +}) +``` + +Path placeholders must look like `{name}`. Empty param values throw at runtime +when a path is resolved. + ### Definition Helpers #### `defineDocument(definition)` @@ -417,6 +506,7 @@ const { params: { projectId: '123' }, readOnly: false, // Optional: override read-only undoable: true, // Optional: enable undo (default: true) + enabled: true, // Optional: set false until required params exist }) ``` @@ -442,6 +532,7 @@ const { params: { projectId: '123' }, queryConstraints: [where('floor', '==', 1)], undoable: true, + enabled: true, // Optional: set false until required params exist }) // Update existing documents @@ -606,7 +697,7 @@ const combined = mergeDiffs(diff1, diff2) ## Notes -- **`enabled` flag** — pass `enabled: false` to `useDocument`/`useCollection` to skip the subscription when params aren't ready (e.g., during a route transition). +- **`enabled` flag** — pass `enabled: false` to generated hooks or to `useDocument`/`useCollection` when route params or auth-derived ids are not ready yet. Disabled hooks do not resolve paths or attach listeners, which avoids building invalid Firestore paths like `projects//spaces`. - **Navigation flicker** — changing `params` rebuilds the listener and briefly shows `isLoading: true`. To keep the previous data visible across the transition, wrap your param in `useDeferredValue`. - **No cross-doc transactions** — writes are atomic per document and per collection (via `writeBatch`), but not across them. For now, use Firestore's `runTransaction` directly via `handle.ref`. - **Per-client undo** — `useUndoManager` is local; one user's undo doesn't propagate to others. @@ -853,6 +944,9 @@ function ProjectEditor({ projectId }) { Contributions are welcome! Please feel free to submit a Pull Request. +See [CONTRIBUTING.md](./CONTRIBUTING.md) for setup, workflow, and testing +guidelines. + ### Development ```bash diff --git a/docs/api-recipes.md b/docs/api-recipes.md new file mode 100644 index 0000000..aa5f53c --- /dev/null +++ b/docs/api-recipes.md @@ -0,0 +1,277 @@ +# API Recipes + +This file collects examples and edge cases that are easy to miss when using or +modifying Firestate. + +## Recommended Registry API + +Use `defineFirestate`, `doc`, and `col` for normal app code. + +```ts +import { z } from 'zod' +import { defineFirestate, doc, col } from '@hvakr/firestate' + +const ProjectSchema = z.object({ + name: z.string(), + createdAt: z.number(), +}) + +const SpaceSchema = z.object({ + name: z.string(), + area: z.number(), + floor: z.number(), +}) + +export const { useProject, useSpaces } = defineFirestate({ + project: doc({ + path: 'projects/{projectId}', + schema: ProjectSchema, + }), + spaces: col({ + path: 'projects/{projectId}/spaces', + schema: SpaceSchema, + lazy: true, + }), +}) +``` + +Generated hooks require exactly the params implied by the path template: + +```tsx +const project = useProject({ projectId }) +const spaces = useSpaces({ projectId }) +``` + +## Lower-Level Escape Hatch + +Use `defineDocument` and `defineCollection` directly when: + +- path derivation does not fit a `{name}` template +- the definition is needed outside React +- a plain TypeScript type is preferred over a Zod schema +- control flow does not fit a module-level registry + +```ts +import { defineDocument } from '@hvakr/firestate' + +interface Project { + name: string + createdAt: number +} + +export const projectDoc = defineDocument({ + collection: (params) => `orgs/${params.orgId}/projects`, + id: (params) => `${params.projectId}-${params.revision}`, +}) +``` + +```tsx +const project = useDocument({ + definition: projectDoc, + params: { orgId, projectId, revision }, +}) +``` + +## Provider Setup + +```tsx +import { FirestateProvider } from '@hvakr/firestate' +import { db } from './firebase' + +export function App() { + return ( + + + + ) +} +``` + +Use `FirestateStoreProvider` only when a pre-created store is needed. + +## Zod Validation + +Schemas validate full writes: + +```ts +project.set({ + name: 'New project', + createdAt: Date.now(), +}) + +spaces.add({ + name: 'Lobby', + area: 500, + floor: 1, +}) +``` + +Firestate calls `schema.parse(...)`, but stores the original object. Parsed +output is not used. This means transforms, coercions, defaults, and stripping +do not affect stored data. Apply transforms before calling `set` or `add` if +you need transformed output. + +Partial updates are not schema-validated: + +```ts +import { serverTimestamp } from 'firebase/firestore' + +project.update({ + updatedAt: serverTimestamp(), +}) +``` + +This is intentional because Firestore sentinels often do not satisfy strict +Zod schemas. + +## Create vs Update + +Use `set` to create or fully replace a document: + +```ts +project.set({ + name: 'New project', + createdAt: Date.now(), +}) +``` + +Use `update` only when current data exists: + +```ts +if (project.data) { + project.update({ name: 'Renamed project' }) +} +``` + +Document `update` is ignored when there is no current data. This prevents +partial diffs from creating broken documents. + +## Lazy Collections + +Lazy collections do not attach a Firestore listener until `load()` is called. + +```ts +const spaces = useSpaces({ projectId }) + +if (!spaces.isActive) { + return +} +``` + +Collection mutations are dropped before the first snapshot. Gate mutations on +`isActive`, `isLoading`, or existing data. + +```ts +if (spaces.isActive && !spaces.isLoading) { + spaces.add({ name: 'Lobby', area: 500, floor: 1 }) +} +``` + +## Enabled Flag + +Use `enabled: false` when params are not ready. Disabled hooks return stable +no-op handles and do not resolve paths, so they are useful during route or auth +loading states where an id would otherwise be empty. + +```tsx +const project = useProject( + { projectId: projectId ?? '' }, + { enabled: Boolean(projectId) } +) +``` + +For the lower-level API: + +```tsx +const project = useDocument({ + definition: projectDoc, + params: projectId ? { projectId } : {}, + enabled: Boolean(projectId), +}) +``` + +Disabled hooks return no-op handles with `isSynced: true` and no Firestore +reference. + +## Query Constraints + +Memoize query constraints. Inline arrays create a new reference on every render +and rebuild the listener. + +```tsx +import { orderBy, where } from 'firebase/firestore' +import { useMemo } from 'react' + +const queryConstraints = useMemo( + () => [where('floor', '==', floor), orderBy('name', 'asc')], + [floor] +) + +const spaces = useSpaces({ projectId }, { queryConstraints }) +``` + +## Undo and Redo + +Undo is enabled by default. + +```tsx +const { undo, redo, canUndo, canRedo } = useUndoManager() +``` + +Skip undo for non-user-facing writes: + +```ts +project.update({ lastViewedAt: Date.now() }, { undoable: false }) +``` + +Group multiple writes into one undo action: + +```ts +const undoGroupId = crypto.randomUUID() + +project.update({ name: 'Renamed' }, { undoGroupId }) +spaces.update({ [spaceId]: { name: 'Main room' } }, { undoGroupId }) +``` + +## Manual Sync + +Set `autosave: 0` to disable debounced writes, then call `sync()` explicitly. + +```ts +const projectDoc = defineDocument({ + schema: ProjectSchema, + collection: 'projects', + id: (params) => params.projectId, + autosave: 0, +}) + +project.update({ name: 'Draft name' }) +await project.sync() +``` + +## Unsaved Changes + +Use global sync state for save indicators and route blockers. + +```tsx +const isSynced = useIsSynced() +const shouldBlock = useUnsavedChangesBlocker() +``` + +Debounced local edits are not flushed automatically when a subscription +unmounts. Call `handle.sync()` before a save-and-close action if the UI can +await the write. + +## Read-Only Handles + +Definitions and hooks can be read-only. + +```ts +const project = useProject({ projectId }, { readOnly: true }) +``` + +Mutation methods on read-only handles return without queueing writes. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..1e32556 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,194 @@ +# Architecture + +Firestate has two public API layers over the same core subscription system. + +The recommended layer is the registry API: + +- `defineFirestate(registry)` creates named React hooks. +- `doc({ path, schema })` declares one document entry. +- `col({ path, schema })` declares one collection entry. + +The lower-level layer is: + +- `defineDocument(definition)` and `defineCollection(definition)` +- `useDocument({ definition, params })` +- `useCollection({ definition, params })` +- `createDocumentSubscription(...)` and `createCollectionSubscription(...)` + +Both layers share the same store, undo manager, diff helpers, autosave logic, +sync tracking, and Firestore listener behavior. + +## Data Flow + +At runtime, a typical React app follows this flow: + +1. The app renders `FirestateProvider` with a Firestore instance. +2. `FirestateProvider` creates a `FirestateStore`. +3. A generated hook from `defineFirestate`, or a direct call to `useDocument` + / `useCollection`, resolves the Firestore path from params. +4. The hook creates a document or collection subscription. +5. The subscription attaches an `onSnapshot` listener when loaded. +6. Snapshots update `syncState`. +7. Mutations update `localState` immediately and schedule autosave. +8. `sync()` computes a diff and writes to Firestore. +9. Later snapshots confirm the write or cause pending local edits to be + rebased over newer server state. + +The public handle always exposes merged data: + +```ts +merged = localState ?? syncState +``` + +For documents, `localState === null` represents a pending delete and surfaces +as `data: undefined`. + +## Registry API + +`src/firestate.ts` owns the registry API. + +Path templates use `{name}` placeholders: + +```ts +doc({ path: 'projects/{projectId}', schema: ProjectSchema }) +col({ path: 'projects/{projectId}/spaces', schema: SpaceSchema }) +``` + +The template is used twice: + +- At the type level, placeholder names become required hook params. +- At runtime, placeholders are interpolated before creating the lower-level + definition. + +Document paths are split at the final slash. For +`projects/{projectId}/revisions/{revisionId}`, the collection path is +`projects/{projectId}/revisions` and the document id template is +`{revisionId}`. + +The registry API intentionally requires Zod schemas. Use the lower-level API +when a plain TypeScript definition or custom path derivation is needed. + +## Definitions + +`src/schema.ts` contains the lower-level definition helpers. They are mostly +identity functions with useful generic overloads. + +`defineDocument` accepts: + +- `collection`: a string or params function +- `id`: a string or params function +- optional `schema` +- autosave, loading, read-only, and retry options + +`defineCollection` accepts: + +- `path`: a string or params function +- optional `schema` +- optional lazy loading +- optional Firestore query constraints +- autosave, loading, read-only, and retry options + +## Store + +`src/store.ts` creates the shared `FirestateStore`. + +The store owns: + +- the Firestore instance +- default autosave and min-load-time config +- the undo manager +- global sync-state tracking +- error reporting + +Each subscription registers a unique sync key. On stop, the subscription must +unregister that key so `useIsSynced()` cannot get stuck on stale unsynced +state. + +## React Hooks + +`src/hooks.ts` wraps subscriptions with `useSyncExternalStore`. + +Important details: + +- Hooks return stable disabled handles when `enabled: false`. +- Disabled hooks do not resolve params or create subscriptions. +- Toggling `undoable` should not rebuild Firestore listeners. +- `queryConstraints` are compared by reference; callers should memoize arrays. +- Subscription handles are cached until state changes, so React sees stable + snapshots between commits. + +## Document Subscriptions + +`src/document.ts` owns single-document behavior. + +State has three local edit cases: + +- `undefined`: no pending local edits +- `null`: pending delete +- object: pending set or update + +`update(diff)`: + +- requires current data +- applies the diff locally +- pushes an undo action unless disabled +- schedules autosave +- syncs later with `updateDoc(flattenDiff(diff))` + +`set(data)`: + +- validates with Zod when a schema is present +- stores the caller's original object, not the parsed value +- creates or replaces the document with `setDoc` + +`delete()`: + +- marks a pending delete locally +- syncs later with `deleteDoc` + +When a snapshot arrives during an inflight write, Firestate compares the +inflight local state with the current local state. If the user made more local +edits while the write was inflight, those edits are rebased onto the new +server snapshot. + +## Collection Subscriptions + +`src/collection.ts` owns collection behavior. + +Collections store data as `Record`, keyed by Firestore document id. +Snapshot data is normalized so each document includes its `id`. + +Collection mutations require the first snapshot. Before that, `add`, `update`, +and `remove` bail rather than guessing what server fields exist. + +`add(data)` can auto-generate an id synchronously. It returns `undefined` if +the mutation is dropped. + +Collection sync uses a Firestore write batch: + +- new docs use `batch.set` +- existing docs use `batch.update` with flattened diffs +- removed docs use `batch.delete` + +## Diff Utilities + +`src/diff.ts` is Firestore-aware: + +- removed fields become `deleteField()` +- arrays are replaced as whole values +- nested plain objects are recursively diffed +- `Timestamp` values are compared and cloned specially +- Firestore sentinels are preserved while flattening + +These helpers are shared by document sync, collection sync, and undo. + +## Undo + +`src/undo.ts` is framework agnostic. + +Subscriptions push undo actions eagerly when local mutations are made. Grouped +actions with the same `groupId` are merged. Undo applies grouped actions from +newest to oldest; redo applies them from oldest to newest. + +Undo state is local to the current store/client. It does not sync through +Firestore.