@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/LICENSE +21 -0
- package/README.md +968 -0
- package/dist/index.d.mts +1105 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1779 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +67 -0
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
|
+
[](https://www.npmjs.com/package/@hvakr/firestate)
|
|
6
|
+
[](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)
|