@hvakr/firestate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,968 @@
1
+ # Firestate
2
+
3
+ Firestore state management for React with real-time sync, undo/redo, optimistic updates, and Zod schema validation.
4
+
5
+ [![npm version](https://badge.fury.io/js/@hvakr%2Ffirestate.svg)](https://www.npmjs.com/package/@hvakr/firestate)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ ## Why Firestate?
9
+
10
+ Managing Firestore state in React applications typically involves:
11
+
12
+ - Setting up real-time listeners with proper cleanup
13
+ - Handling optimistic updates and conflict resolution
14
+ - Tracking sync state across multiple documents/collections
15
+ - Implementing undo/redo functionality
16
+ - Lots of boilerplate code that's easy to get wrong
17
+
18
+ Firestate provides a declarative, schema-first approach that eliminates boilerplate while giving you production-ready features out of the box.
19
+
20
+ ## Features
21
+
22
+ - **Zod schemas as the source of truth**: each document/collection is declared with a [Zod](https://zod.dev) schema; firestate infers the TypeScript type via `z.infer` and validates writes at runtime
23
+ - **Real-time sync**: Automatic Firestore listeners with proper lifecycle management
24
+ - **Optimistic updates**: Changes reflect immediately, sync in background
25
+ - **Conflict resolution**: Automatic rebasing when concurrent changes occur
26
+ - **Undo/redo**: Built-in command pattern with action grouping
27
+ - **Lazy loading**: Collections can defer subscription until needed
28
+ - **Diff-based updates**: Only changed fields are sent to Firestore
29
+
30
+ ## Choosing an API
31
+
32
+ Firestate exposes two layers. Pick one based on what you're building:
33
+
34
+ - **`createFirestate` + `doc` / `col`** (recommended for app code) — declare every Firestore thing in a single registry object; the library generates one typed React hook per entry. Each entry takes a `path` template and a Zod `schema`. In return you get:
35
+ - the data type (`TaskList`) inferred from the schema via `z.infer`
36
+ - the param keys (`{ listId }`) inferred from the path template and enforced at call sites
37
+ - runtime validation on `set` / `add` writes — bad data throws at the call site instead of after a Firestore round trip
38
+
39
+ Partial `update(diff)` calls are intentionally NOT validated: diffs commonly include Firestore sentinels like `serverTimestamp()` that a strict schema would reject.
40
+
41
+ ```ts
42
+ import { z } from 'zod'
43
+ import { createFirestate, doc, col } from '@hvakr/firestate'
44
+
45
+ const TaskListSchema = z.object({ name: z.string(), createdAt: z.number() })
46
+ const TaskSchema = z.object({ title: z.string(), completed: z.boolean() })
47
+
48
+ export const { useTaskList, useTasks } = createFirestate({
49
+ taskList: doc({ path: 'taskLists/{listId}', schema: TaskListSchema }),
50
+ tasks: col({ path: 'taskLists/{listId}/tasks', schema: TaskSchema }),
51
+ })
52
+
53
+ // useTaskList({ listId }) — { listId: string } statically required
54
+ // useTaskList() — type error: missing listId
55
+ // useTaskList({ wrong: 'a' }) — type error: wrong key
56
+ ```
57
+
58
+ - **`defineDocument` / `defineCollection` + `useDocument` / `useCollection`** (lower-level escape hatch) — write the path-derivation function yourself, use the standalone hooks. Reach for these when:
59
+ - your path doesn't fit the `{name}` template (computed from non-string state, conditional segments)
60
+ - you need the definition outside React (Node scripts, server-side, tests)
61
+ - your control flow doesn't fit a module-level registry
62
+ - you want plain TypeScript types without a Zod schema (the schema field is optional here)
63
+
64
+ Both layers share the same store, undo manager, and sync semantics — the registry is a thin layer on top of the lower-level primitives.
65
+
66
+ ## Table of Contents
67
+
68
+ - [Choosing an API](#choosing-an-api)
69
+ - [Installation](#installation)
70
+ - [Quick Start](#quick-start)
71
+ - [Examples](#examples)
72
+ - [Documentation](#documentation)
73
+ - [Core Concepts](#core-concepts)
74
+ - [API Reference](#api-reference)
75
+ - [Diff Utilities](#diff-utilities)
76
+ - [Advanced Usage](#advanced-usage)
77
+ - [Testing](#testing)
78
+ - [Contributing](#contributing)
79
+
80
+ ## Installation
81
+
82
+ ```bash
83
+ pnpm add @hvakr/firestate
84
+ # or
85
+ npm install @hvakr/firestate
86
+ # or
87
+ yarn add @hvakr/firestate
88
+ ```
89
+
90
+ ### Peer Dependencies
91
+
92
+ Firestate requires the following peer dependencies:
93
+
94
+ ```json
95
+ {
96
+ "firebase": "^10.0.0 || ^11.0.0",
97
+ "react": "^18.0.0 || ^19.0.0",
98
+ "zod": "^4.0.0"
99
+ }
100
+ ```
101
+
102
+ Firestate is opinionated about Zod 4. Schemas drive both the inferred
103
+ TypeScript types and runtime validation on `set` / `add` writes.
104
+
105
+ ## Quick Start
106
+
107
+ ### 1. Define your data
108
+
109
+ ```typescript
110
+ // schemas.ts
111
+ import { defineDocument, defineCollection } from '@hvakr/firestate'
112
+
113
+ // Plain TypeScript interfaces — no runtime validator required
114
+ interface Project {
115
+ name: string
116
+ description?: string
117
+ createdAt: number
118
+ updatedAt: number
119
+ }
120
+
121
+ interface Space {
122
+ name: string
123
+ area: number
124
+ floor: number
125
+ }
126
+
127
+ // Create a document definition
128
+ export const projectDoc = defineDocument<Project>({
129
+ collection: 'projects',
130
+ id: (params) => params.projectId,
131
+ autosave: 1000, // Debounce writes by 1 second
132
+ })
133
+
134
+ // Create a collection definition
135
+ export const spacesCollection = defineCollection<Space>({
136
+ path: (params) => `projects/${params.projectId}/spaces`,
137
+ lazy: true, // Only subscribe when load() is called
138
+ })
139
+ ```
140
+
141
+ #### Validating with Zod
142
+
143
+ Pass a Zod schema via the `schema` field. `TData` is inferred from
144
+ `z.infer<typeof Schema>`, and firestate runs `schema.parse(...)` on
145
+ `set` / `add` writes — bad data throws at the call site rather than
146
+ after a Firestore round trip. Partial `update(diff)` is not validated
147
+ (diffs frequently carry Firestore sentinels).
148
+
149
+ ```typescript
150
+ import { z } from 'zod'
151
+ import { defineDocument } from '@hvakr/firestate'
152
+
153
+ const ProjectSchema = z.object({
154
+ name: z.string(),
155
+ description: z.string().optional(),
156
+ createdAt: z.number(),
157
+ updatedAt: z.number(),
158
+ })
159
+
160
+ export const projectDoc = defineDocument({
161
+ schema: ProjectSchema,
162
+ collection: 'projects',
163
+ id: (params) => params.projectId,
164
+ })
165
+ ```
166
+
167
+ ### 2. Set up the provider
168
+
169
+ ```tsx
170
+ // App.tsx
171
+ import { FirestateProvider } from '@hvakr/firestate'
172
+ import { db } from './firebase'
173
+
174
+ function App() {
175
+ return (
176
+ <FirestateProvider
177
+ firestore={db}
178
+ autosave={1000}
179
+ maxUndoLength={20}
180
+ >
181
+ <YourApp />
182
+ </FirestateProvider>
183
+ )
184
+ }
185
+ ```
186
+
187
+ ### 3. Use in components
188
+
189
+ ```tsx
190
+ // ProjectEditor.tsx
191
+ import { useDocument, useCollection, useUndoManager } from '@hvakr/firestate'
192
+ import { projectDoc, spacesCollection } from './schemas'
193
+
194
+ function ProjectEditor({ projectId }: { projectId: string }) {
195
+ const params = { projectId }
196
+
197
+ // Subscribe to project document
198
+ const project = useDocument({ definition: projectDoc, params })
199
+
200
+ // Subscribe to spaces collection (lazy)
201
+ const spaces = useCollection({ definition: spacesCollection, params })
202
+
203
+ // Access undo/redo
204
+ const { undo, redo, canUndo, canRedo } = useUndoManager()
205
+
206
+ if (project.isLoading) return <Spinner />
207
+ if (!project.data) return <NotFound />
208
+
209
+ return (
210
+ <div>
211
+ {/* Undo/Redo buttons */}
212
+ <button onClick={undo} disabled={!canUndo}>Undo</button>
213
+ <button onClick={redo} disabled={!canRedo}>Redo</button>
214
+
215
+ {/* Edit project name - changes auto-save */}
216
+ <input
217
+ value={project.data.name}
218
+ onChange={(e) => project.update({ name: e.target.value })}
219
+ />
220
+
221
+ {/* Lazy-load spaces */}
222
+ {!spaces.isActive ? (
223
+ <button onClick={spaces.load}>Load Spaces</button>
224
+ ) : spaces.isLoading ? (
225
+ <Spinner />
226
+ ) : (
227
+ <ul>
228
+ {Object.values(spaces.data).map((space) => (
229
+ <li key={space.id}>
230
+ {space.name} - {space.area} sq ft
231
+ </li>
232
+ ))}
233
+ </ul>
234
+ )}
235
+
236
+ {/* Sync indicator */}
237
+ {!project.isSynced && <span>Saving...</span>}
238
+ </div>
239
+ )
240
+ }
241
+ ```
242
+
243
+ ## Examples
244
+
245
+ Check out the [examples](./examples) directory for complete, runnable examples:
246
+
247
+ - **[React Tasks](./examples/react-tasks)** - A simple task manager demonstrating documents, collections, undo/redo, sync indicators, and real-time updates.
248
+
249
+ ## Documentation
250
+
251
+ - [Architecture](./docs/architecture.md) - how the registry API, hooks, store, subscriptions, diffing, sync, and undo layers fit together.
252
+ - [API Recipes](./docs/api-recipes.md) - focused examples for common usage patterns and edge cases.
253
+ - [Contributing](./CONTRIBUTING.md) - local setup, commands, tests, and release notes.
254
+ - [Agent Guide](./AGENTS.md) - repo map and behavioral contracts for AI coding agents.
255
+ - [Claude Instructions](./CLAUDE.md) - short pointer for Claude Code.
256
+
257
+ ## Core Concepts
258
+
259
+ ### Documents vs Collections
260
+
261
+ - **Document**: A single Firestore document with a known path
262
+ - **Collection**: A set of documents, optionally with query constraints
263
+
264
+ ### Optimistic Updates
265
+
266
+ When you call `update()`, the change is applied immediately to local state. The library then:
267
+
268
+ 1. Computes the minimal diff
269
+ 2. Debounces writes (configurable `autosave` interval)
270
+ 3. Sends only changed fields to Firestore using dot-notation (flattened keys)
271
+ 4. Handles any conflicts from concurrent changes
272
+
273
+ ### Update vs Set
274
+
275
+ Firestate uses Firestore's `updateDoc` for partial updates and `setDoc` for full replacements:
276
+
277
+ - **`update(diff)`** - Uses `updateDoc` with flattened dot-notation keys. This prevents accidentally recreating a document that was deleted by another user. If the document doesn't exist, the update will fail.
278
+
279
+ - **`set(data)`** - Uses `setDoc` to create or completely replace a document. Use this when you intentionally want to create a new document or overwrite an existing one.
280
+
281
+ ```tsx
282
+ // Partial update - only changes 'name', fails if document was deleted
283
+ project.update({ name: 'New Name' })
284
+
285
+ // Full replacement - creates document if it doesn't exist
286
+ project.set({ name: 'New Project', createdAt: Date.now() })
287
+ ```
288
+
289
+ This distinction is important for collaborative applications where multiple users may be editing simultaneously.
290
+
291
+ ### Undo/Redo
292
+
293
+ Every undoable update automatically creates an undo action. Actions with the same `undoGroupId` are merged:
294
+
295
+ ```tsx
296
+ const groupId = crypto.randomUUID()
297
+
298
+ // These two updates become a single undo action
299
+ project.update({ name: 'New Name' }, { undoGroupId: groupId })
300
+ spaces.update({ space1: { name: 'Updated' } }, { undoGroupId: groupId })
301
+ ```
302
+
303
+ To skip undo tracking:
304
+
305
+ ```tsx
306
+ project.update({ lastViewed: Date.now() }, { undoable: false })
307
+ ```
308
+
309
+ ### Lazy Collections
310
+
311
+ For large applications, you may not want to subscribe to every collection immediately:
312
+
313
+ ```tsx
314
+ const spacesCollection = defineCollection({
315
+ schema: SpaceSchema,
316
+ path: (params) => `projects/${params.projectId}/spaces`,
317
+ lazy: true, // Don't subscribe until load() is called
318
+ })
319
+
320
+ // In component
321
+ const spaces = useCollection({ definition: spacesCollection, params })
322
+ spaces.load() // Start subscription
323
+ ```
324
+
325
+ ### Sync State Tracking
326
+
327
+ The library tracks whether all documents/collections are synced:
328
+
329
+ ```tsx
330
+ import { useIsSynced, useUnsavedChangesBlocker } from '@hvakr/firestate'
331
+
332
+ function App() {
333
+ const isSynced = useIsSynced()
334
+ const shouldBlock = useUnsavedChangesBlocker()
335
+
336
+ // Use with react-router's useBlocker
337
+ const blocker = useBlocker(
338
+ ({ currentLocation, nextLocation }) =>
339
+ currentLocation.pathname !== nextLocation.pathname && shouldBlock
340
+ )
341
+
342
+ return (
343
+ <>
344
+ {!isSynced && <SavingIndicator />}
345
+ {blocker.state === 'blocked' && (
346
+ <Dialog>Your changes may not be saved!</Dialog>
347
+ )}
348
+ </>
349
+ )
350
+ }
351
+ ```
352
+
353
+ ### Pending edits on unmount
354
+
355
+ Writes are debounced by `autosave` (default 1000 ms). If a component unmounts
356
+ while there are unflushed local edits, those edits are dropped silently — the
357
+ subscription is gone and the autosave timer is cleared. To handle this:
358
+
359
+ - **Block navigation** with `useUnsavedChangesBlocker` (shown above) so users
360
+ can't navigate away while writes are pending.
361
+ - **Force a flush** by calling `handle.sync()` before triggering the unmount
362
+ (e.g., in a custom save-and-close button).
363
+ - **Lower `autosave`** if the debounce window is the source of risk.
364
+
365
+ There is no automatic flush in the subscription's `stop()` because `stop()`
366
+ is synchronous and consumers may unmount during route transitions where
367
+ awaiting writes is not feasible.
368
+
369
+ ## API Reference
370
+
371
+ ### Registry API
372
+
373
+ #### `createFirestate(registry)`
374
+
375
+ Creates typed React hooks from a registry object. Each key becomes a hook named
376
+ `use{CapitalizedKey}`.
377
+
378
+ ```typescript
379
+ import { z } from 'zod'
380
+ import { createFirestate, doc, col } from '@hvakr/firestate'
381
+
382
+ const ProjectSchema = z.object({
383
+ name: z.string(),
384
+ createdAt: z.number(),
385
+ })
386
+
387
+ const SpaceSchema = z.object({
388
+ name: z.string(),
389
+ area: z.number(),
390
+ floor: z.number(),
391
+ })
392
+
393
+ export const { useProject, useSpaces } = createFirestate({
394
+ project: doc({
395
+ path: 'projects/{projectId}',
396
+ schema: ProjectSchema,
397
+ }),
398
+ spaces: col({
399
+ path: 'projects/{projectId}/spaces',
400
+ schema: SpaceSchema,
401
+ lazy: true,
402
+ }),
403
+ })
404
+ ```
405
+
406
+ Generated hooks require the params implied by the path template:
407
+
408
+ ```tsx
409
+ const project = useProject({ projectId })
410
+ const spaces = useSpaces({ projectId })
411
+ ```
412
+
413
+ Use the second argument for hook options such as `enabled`, `readOnly`,
414
+ `undoable`, or collection `queryConstraints`:
415
+
416
+ ```tsx
417
+ const spaces = useSpaces(
418
+ { projectId },
419
+ {
420
+ enabled: Boolean(projectId),
421
+ queryConstraints,
422
+ }
423
+ )
424
+ ```
425
+
426
+ #### `doc(options)` and `col(options)`
427
+
428
+ Declare registry entries. A Zod `schema` is required and drives both the
429
+ generated TypeScript data type and runtime validation for full writes.
430
+
431
+ ```typescript
432
+ doc({
433
+ path: 'projects/{projectId}',
434
+ schema: ProjectSchema,
435
+ autosave: 1000,
436
+ readOnly: false,
437
+ retryOnError: false,
438
+ })
439
+
440
+ col({
441
+ path: 'projects/{projectId}/spaces',
442
+ schema: SpaceSchema,
443
+ lazy: true,
444
+ queryConstraints: [],
445
+ })
446
+ ```
447
+
448
+ Path placeholders must look like `{name}`. Empty param values throw at runtime
449
+ when a path is resolved.
450
+
451
+ ### Definition Helpers
452
+
453
+ #### `defineDocument(definition)`
454
+
455
+ Creates a document definition. Provide the document shape via the `TData`
456
+ type parameter, or let it be inferred from a Zod schema.
457
+
458
+ ```typescript
459
+ const projectDoc = defineDocument<Project>({
460
+ collection: 'projects', // Collection path
461
+ id: (params) => params.id, // Document ID (string or function)
462
+ autosave: 1000, // Optional: debounce interval (ms)
463
+ minLoadTime: 0, // Optional: minimum loading time (ms)
464
+ readOnly: false, // Optional: prevent updates
465
+ retryOnError: false, // Optional: retry on listener errors
466
+ retryInterval: 5000, // Optional: retry interval (ms)
467
+ schema: ProjectSchema, // Optional: Zod schema (validates set/add)
468
+ })
469
+ ```
470
+
471
+ #### `defineCollection(definition)`
472
+
473
+ Creates a collection definition.
474
+
475
+ ```typescript
476
+ const spacesCollection = defineCollection<Space>({
477
+ path: (params) => `projects/${params.id}/spaces`, // Collection path
478
+ autosave: 1000, // Optional: debounce interval
479
+ minLoadTime: 0, // Optional: minimum loading time
480
+ readOnly: false, // Optional: prevent updates
481
+ lazy: false, // Optional: defer subscription
482
+ queryConstraints: [], // Optional: Firestore constraints
483
+ schema: SpaceSchema, // Optional: Zod schema (validates add)
484
+ })
485
+ ```
486
+
487
+ ### React Hooks
488
+
489
+ #### `useDocument(options)`
490
+
491
+ Subscribe to a document.
492
+
493
+ ```typescript
494
+ const {
495
+ data, // Current document data (T | undefined)
496
+ update, // Update with partial diff
497
+ set, // Replace entire document
498
+ delete: del, // Delete the document
499
+ isLoading, // Whether initial data is loading
500
+ isSynced, // Whether all changes are synced
501
+ sync, // Force sync immediately
502
+ error, // Error from listener, if any
503
+ ref, // Firestore DocumentReference
504
+ } = useDocument({
505
+ definition: projectDoc,
506
+ params: { projectId: '123' },
507
+ readOnly: false, // Optional: override read-only
508
+ undoable: true, // Optional: enable undo (default: true)
509
+ enabled: true, // Optional: set false until required params exist
510
+ })
511
+ ```
512
+
513
+ #### `useCollection(options)`
514
+
515
+ Subscribe to a collection.
516
+
517
+ ```typescript
518
+ const {
519
+ data, // Record<string, T> of documents
520
+ update, // Update one or more documents
521
+ add, // Add a new document (explicit or auto-generated id)
522
+ remove, // Remove a document
523
+ isLoading, // Whether initial data is loading
524
+ isSynced, // Whether all changes are synced
525
+ isActive, // Whether subscription is active
526
+ load, // Activate a lazy subscription
527
+ sync, // Force sync immediately
528
+ error, // Error from listener, if any
529
+ ref, // Firestore CollectionReference
530
+ } = useCollection({
531
+ definition: spacesCollection,
532
+ params: { projectId: '123' },
533
+ queryConstraints: [where('floor', '==', 1)],
534
+ undoable: true,
535
+ enabled: true, // Optional: set false until required params exist
536
+ })
537
+
538
+ // Update existing documents
539
+ update({ space1: { name: 'Updated Name' } })
540
+
541
+ // Add a new document with an explicit id
542
+ add('newSpaceId', { name: 'New Space', area: 500, floor: 1 })
543
+
544
+ // Or let Firestore generate the id — returned synchronously
545
+ const id = add({ name: 'New Space', area: 500, floor: 1 })
546
+
547
+ // Remove a document
548
+ remove('oldSpaceId')
549
+ ```
550
+
551
+ #### `useUndoManager()`
552
+
553
+ Access the undo manager.
554
+
555
+ ```typescript
556
+ const {
557
+ canUndo, // Whether undo is available
558
+ canRedo, // Whether redo is available
559
+ undo, // Undo the last action
560
+ redo, // Redo the last undone action
561
+ clear, // Clear undo/redo history
562
+ undoStack, // Array of undo actions
563
+ redoStack, // Array of redo actions
564
+ } = useUndoManager()
565
+ ```
566
+
567
+ #### `useIsSynced()`
568
+
569
+ Check if all tracked resources are synced.
570
+
571
+ ```typescript
572
+ const isSynced = useIsSynced()
573
+ ```
574
+
575
+ #### `useUndoKeyboardShortcuts()`
576
+
577
+ Add Ctrl/Cmd+Z and Ctrl/Cmd+Y keyboard shortcuts.
578
+
579
+ ```typescript
580
+ useUndoKeyboardShortcuts()
581
+ ```
582
+
583
+ ### Providers
584
+
585
+ #### `FirestateProvider`
586
+
587
+ Main provider component.
588
+
589
+ ```tsx
590
+ <FirestateProvider
591
+ firestore={db} // Required: Firestore instance
592
+ autosave={1000} // Optional: default debounce (ms)
593
+ minLoadTime={0} // Optional: minimum loading time (ms)
594
+ maxUndoLength={20} // Optional: max undo stack size
595
+ onError={(error, context) => {
596
+ // Optional: custom error handler
597
+ console.error(context.path, error)
598
+ }}
599
+ >
600
+ {children}
601
+ </FirestateProvider>
602
+ ```
603
+
604
+ #### `FirestateStoreProvider`
605
+
606
+ Use with a pre-created store for more control.
607
+
608
+ ```tsx
609
+ import { createStore, FirestateStoreProvider } from '@hvakr/firestate'
610
+
611
+ const store = createStore({ firestore: db })
612
+
613
+ <FirestateStoreProvider store={store}>
614
+ {children}
615
+ </FirestateStoreProvider>
616
+ ```
617
+
618
+ ## Diff Utilities
619
+
620
+ Firestate exports a comprehensive set of diff utilities that can be used throughout your application and backend.
621
+
622
+ ### Core Diff Operations
623
+
624
+ ```typescript
625
+ import {
626
+ computeDiff,
627
+ applyDiff,
628
+ applyDiffMutable,
629
+ computeUndoDiff,
630
+ } from '@hvakr/firestate'
631
+
632
+ // Compute minimal diff between two objects
633
+ const diff = computeDiff(oldState, newState)
634
+
635
+ // Apply diff (returns new object, original unchanged)
636
+ const newState = applyDiff(currentState, diff)
637
+
638
+ // Apply diff in place (mutates target object) - use for performance-critical paths
639
+ applyDiffMutable(targetState, diff)
640
+
641
+ // Compute the undo diff - what would reverse these changes
642
+ const undoDiff = computeUndoDiff(startState, diff)
643
+ // Applying undoDiff to the result restores startState
644
+ ```
645
+
646
+ ### Flattening for Firestore
647
+
648
+ ```typescript
649
+ import { flattenDiff, unflattenDiff } from '@hvakr/firestate'
650
+
651
+ // Flatten nested diff to dot-notation for Firestore's updateDoc
652
+ const nested = { building: { floors: 5, height: 100 } }
653
+ const flat = flattenDiff(nested)
654
+ // { 'building.floors': 5, 'building.height': 100 }
655
+
656
+ // Unflatten back to nested structure
657
+ const restored = unflattenDiff(flat)
658
+ // { building: { floors: 5, height: 100 } }
659
+ ```
660
+
661
+ ### Path-Based Utilities
662
+
663
+ ```typescript
664
+ import { diffContainsPath, extractDiffValue, createDiffAtPath } from '@hvakr/firestate'
665
+
666
+ const diff = { building: { floors: 5 }, name: 'Test' }
667
+
668
+ // Check if a path is affected by a diff
669
+ diffContainsPath(diff, 'building.floors') // true
670
+ diffContainsPath(diff, 'building.height') // false
671
+
672
+ // Extract value at a path
673
+ extractDiffValue(diff, 'building.floors') // 5
674
+
675
+ // Create a diff at a specific path
676
+ createDiffAtPath('building.config.enabled', true)
677
+ // { building: { config: { enabled: true } } }
678
+ ```
679
+
680
+ ### General Utilities
681
+
682
+ ```typescript
683
+ import { isDeepEqual, deepClone, isDiffEmpty, mergeDiffs } from '@hvakr/firestate'
684
+
685
+ // Deep equality check (handles Timestamps, arrays, nested objects)
686
+ isDeepEqual(obj1, obj2)
687
+
688
+ // Deep clone (safe for Firestore operations, handles Timestamps)
689
+ const clone = deepClone(original)
690
+
691
+ // Check if a diff has no changes
692
+ if (isDiffEmpty(diff)) return
693
+
694
+ // Merge two diffs (second takes precedence)
695
+ const combined = mergeDiffs(diff1, diff2)
696
+ ```
697
+
698
+ ## Notes
699
+
700
+ - **`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`.
701
+ - **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`.
702
+ - **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`.
703
+ - **Per-client undo** — `useUndoManager` is local; one user's undo doesn't propagate to others.
704
+ - **Multi-tab sync** — handled automatically by Firestore's listeners; no extra setup.
705
+
706
+ ## Advanced Usage
707
+
708
+ ### Creating a Store Manually
709
+
710
+ For advanced use cases, you can create and manage the store yourself:
711
+
712
+ ```typescript
713
+ import { createStore, createDocumentSubscription } from '@hvakr/firestate'
714
+
715
+ const store = createStore({
716
+ firestore: db,
717
+ autosave: 1000,
718
+ maxUndoLength: 50,
719
+ onError: (error, context) => {
720
+ // Send to error tracking service
721
+ Sentry.captureException(error, { extra: context })
722
+ }
723
+ })
724
+
725
+ const subscription = createDocumentSubscription({
726
+ store,
727
+ definition: projectDoc,
728
+ docId: '123',
729
+ })
730
+
731
+ subscription.subscribe((state) => {
732
+ console.log('State changed:', state)
733
+ })
734
+
735
+ subscription.load()
736
+
737
+ // Later: cleanup
738
+ subscription.stop()
739
+ ```
740
+
741
+ ### Custom Undo Manager
742
+
743
+ Create a standalone undo manager with navigation support:
744
+
745
+ ```typescript
746
+ import { createUndoManager } from '@hvakr/firestate'
747
+
748
+ const undoManager = createUndoManager({
749
+ maxLength: 50,
750
+ onNavigate: (path) => router.push(path),
751
+ })
752
+
753
+ undoManager.push({
754
+ undo: () => restoreOldValue(),
755
+ redo: () => applyNewValue(),
756
+ groupId: 'myGroup',
757
+ path: '/projects/123', // Navigate here on undo/redo
758
+ description: 'Update project name',
759
+ })
760
+
761
+ // Subscribe to state changes
762
+ const unsubscribe = undoManager.subscribe((state) => {
763
+ console.log('Can undo:', state.canUndo)
764
+ console.log('Can redo:', state.canRedo)
765
+ })
766
+ ```
767
+
768
+ ### Query Constraints
769
+
770
+ Add Firestore query constraints to collections:
771
+
772
+ ```typescript
773
+ import { where, orderBy, limit } from 'firebase/firestore'
774
+
775
+ const recentSpaces = useCollection({
776
+ definition: spacesCollection,
777
+ params: { projectId: '123' },
778
+ queryConstraints: [
779
+ where('floor', '>=', 1),
780
+ orderBy('createdAt', 'desc'),
781
+ limit(10),
782
+ ],
783
+ })
784
+ ```
785
+
786
+ ### Handling Errors
787
+
788
+ ```typescript
789
+ const project = useDocument({
790
+ definition: projectDoc,
791
+ params: { projectId: '123' },
792
+ })
793
+
794
+ // Missing documents are not errors — `data` is undefined and `isLoading`
795
+ // is false. Render a create/empty state for that case.
796
+ if (!project.isLoading && !project.data) {
797
+ return <CreateProject />
798
+ }
799
+
800
+ if (project.error) {
801
+ return <ErrorDisplay error={project.error} />
802
+ }
803
+ ```
804
+
805
+ ### Disabling Autosave
806
+
807
+ For cases where you want manual control:
808
+
809
+ ```typescript
810
+ const projectDoc = defineDocument({
811
+ schema: ProjectSchema,
812
+ collection: 'projects',
813
+ id: (params) => params.id,
814
+ autosave: 0, // Disable autosave
815
+ })
816
+
817
+ // In component
818
+ const project = useDocument({ definition: projectDoc, params })
819
+
820
+ // Changes won't auto-save
821
+ project.update({ name: 'New Name' })
822
+
823
+ // Manually sync when ready
824
+ await project.sync()
825
+ ```
826
+
827
+ ## Testing
828
+
829
+ Run tests:
830
+
831
+ ```bash
832
+ pnpm test
833
+ ```
834
+
835
+ Run tests in watch mode:
836
+
837
+ ```bash
838
+ pnpm test:watch
839
+ ```
840
+
841
+ Run tests with coverage:
842
+
843
+ ```bash
844
+ pnpm test:coverage
845
+ ```
846
+
847
+ ### Mocking in Tests
848
+
849
+ When testing components that use Firestate, you can mock the hooks:
850
+
851
+ ```typescript
852
+ import { vi } from 'vitest'
853
+ import * as firestate from '@hvakr/firestate'
854
+
855
+ vi.mock('@hvakr/firestate', () => ({
856
+ useDocument: vi.fn(() => ({
857
+ data: { id: '123', name: 'Test Project' },
858
+ update: vi.fn(),
859
+ set: vi.fn(),
860
+ delete: vi.fn(),
861
+ isLoading: false,
862
+ isSynced: true,
863
+ sync: vi.fn(),
864
+ error: undefined,
865
+ ref: {},
866
+ })),
867
+ useUndoManager: vi.fn(() => ({
868
+ canUndo: false,
869
+ canRedo: false,
870
+ undo: vi.fn(),
871
+ redo: vi.fn(),
872
+ })),
873
+ }))
874
+ ```
875
+
876
+ ## Migration from useFirestoreDocument/Collection
877
+
878
+ If you're currently using custom hooks like `useFirestoreDocument` and `useFirestoreCollection`, here's how to migrate:
879
+
880
+ ### Before (500+ lines of provider code)
881
+
882
+ ```tsx
883
+ // ProjectProvider.tsx
884
+ export const ProjectProvider = ({ children }) => {
885
+ const undoManager = useUndoManager()
886
+
887
+ const project = useFirestoreDocument({
888
+ firestore: db,
889
+ collectionPath: 'projects',
890
+ documentId: projectId,
891
+ autosave: 1000,
892
+ onPushUndoAction: undoManager.push,
893
+ })
894
+
895
+ const spaces = useFirestoreCollection({
896
+ firestore: db,
897
+ collectionPath: `projects/${projectId}/spaces`,
898
+ autosave: 1000,
899
+ lazy: true,
900
+ onPushUndoAction: undoManager.push,
901
+ })
902
+
903
+ // ... 20 more collections ...
904
+
905
+ const allSynced = project.isSynced && spaces.isSynced && /* ... */
906
+
907
+ // ... lots of memoization and context setup ...
908
+ }
909
+ ```
910
+
911
+ ### After (declarative and minimal)
912
+
913
+ ```tsx
914
+ // schemas.ts
915
+ export const projectDoc = defineDocument<Project>({
916
+ collection: 'projects',
917
+ id: (params) => params.projectId,
918
+ })
919
+
920
+ export const spacesCollection = defineCollection<Space>({
921
+ path: (params) => `projects/${params.projectId}/spaces`,
922
+ lazy: true,
923
+ })
924
+
925
+ // Component.tsx
926
+ function ProjectEditor({ projectId }) {
927
+ const project = useDocument({ definition: projectDoc, params: { projectId } })
928
+ const spaces = useCollection({ definition: spacesCollection, params: { projectId } })
929
+ const isSynced = useIsSynced() // Automatic!
930
+
931
+ // That's it. Undo/redo is automatic.
932
+ }
933
+ ```
934
+
935
+ ## Design Philosophy
936
+
937
+ 1. **Schema-first**: A Zod schema per document/collection drives both the inferred type and runtime validation on writes
938
+ 2. **Declarative over imperative**: Define what you want, not how to get it
939
+ 3. **Batteries included**: Undo/redo, sync tracking, and conflict resolution work out of the box
940
+ 4. **Escape hatches**: Low-level APIs available when you need them
941
+ 5. **Framework agnostic core**: The subscription system works without React
942
+
943
+ ## Contributing
944
+
945
+ Contributions are welcome! Please feel free to submit a Pull Request.
946
+
947
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) for setup, workflow, and testing
948
+ guidelines.
949
+
950
+ ### Development
951
+
952
+ ```bash
953
+ # Install dependencies
954
+ pnpm install
955
+
956
+ # Build
957
+ pnpm build
958
+
959
+ # Run tests
960
+ pnpm test
961
+
962
+ # Type check
963
+ pnpm typecheck
964
+ ```
965
+
966
+ ## License
967
+
968
+ MIT © [HVAKR](https://github.com/hvakr)