@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.
@@ -0,0 +1,1105 @@
1
+ import * as react0 from "react";
2
+ import React from "react";
3
+ import { CollectionReference, DocumentReference, Firestore, QueryConstraint, WithFieldValue } from "firebase/firestore";
4
+ import { ZodType, z } from "zod";
5
+
6
+ //#region src/types.d.ts
7
+ /**
8
+ * Deep partial type that works with Records and nested objects
9
+ */
10
+ type DeepPartial<T> = T extends object ? { [P in keyof T]?: DeepPartial<T[P]> } : T;
11
+ /**
12
+ * A generic object that can be stored in Firestore.
13
+ *
14
+ * Uses an `any` index signature (matching Firestore's own `DocumentData`) so
15
+ * that plain TypeScript interfaces — which lack an implicit index signature —
16
+ * can satisfy the constraint. Internal call sites cast through more specific
17
+ * types where needed.
18
+ */
19
+ type FirestoreObject = Record<string, any>;
20
+ /**
21
+ * Options for update operations
22
+ */
23
+ interface UpdateOptions {
24
+ /** If false, prevents this update from being added to undo stack */
25
+ undoable?: boolean;
26
+ /** Group multiple updates into a single undo action */
27
+ undoGroupId?: string;
28
+ }
29
+ /**
30
+ * State of a document subscription
31
+ */
32
+ interface DocumentState<T> {
33
+ /** Current merged state (local changes applied to sync state) */
34
+ data: T | undefined;
35
+ /** Whether initial data has loaded */
36
+ isLoading: boolean;
37
+ /** Whether there are pending local changes */
38
+ isSynced: boolean;
39
+ /** Error from listener, if any */
40
+ error: Error | undefined;
41
+ }
42
+ /**
43
+ * State of a collection subscription
44
+ */
45
+ interface CollectionState<T> {
46
+ /** Current merged state keyed by document ID */
47
+ data: Record<string, T>;
48
+ /** Whether initial data has loaded */
49
+ isLoading: boolean;
50
+ /** Whether there are pending local changes */
51
+ isSynced: boolean;
52
+ /** Whether the collection has been activated (for lazy loading) */
53
+ isActive: boolean;
54
+ /** Error from listener, if any */
55
+ error: Error | undefined;
56
+ }
57
+ /**
58
+ * Document handle returned by useDocument hook
59
+ */
60
+ interface DocumentHandle<T extends FirestoreObject> {
61
+ /** Current document data */
62
+ data: T | undefined;
63
+ /** Update the document with a partial diff */
64
+ update: (diff: WithFieldValue<DeepPartial<T>>, options?: UpdateOptions) => void;
65
+ /** Set the document data (creates or overwrites) */
66
+ set: (data: T, options?: UpdateOptions) => void;
67
+ /** Delete the document */
68
+ delete: (options?: UpdateOptions) => void;
69
+ /** Whether initial data is loading */
70
+ isLoading: boolean;
71
+ /** Whether all changes have synced to Firestore */
72
+ isSynced: boolean;
73
+ /** Force sync pending changes immediately */
74
+ sync: () => Promise<void>;
75
+ /** Error from listener, if any */
76
+ error: Error | undefined;
77
+ /**
78
+ * Firestore document reference. Undefined when the hook was called with
79
+ * `enabled: false` (no subscription was created).
80
+ */
81
+ ref: DocumentReference<T> | undefined;
82
+ }
83
+ /**
84
+ * Collection handle returned by useCollection hook
85
+ */
86
+ interface CollectionHandle<T extends FirestoreObject> {
87
+ /** Current collection data keyed by document ID */
88
+ data: Record<string, T>;
89
+ /** Update one or more documents with partial diffs */
90
+ update: (diff: WithFieldValue<DeepPartial<Record<string, T>>>, options?: UpdateOptions) => void;
91
+ /**
92
+ * Add a new document to the collection. Either pass an explicit `id`, or
93
+ * omit it to have Firestore generate an auto-id (returned synchronously).
94
+ *
95
+ * Returns `undefined` if the mutation was dropped (read-only handle, or
96
+ * called before the first snapshot has arrived). Callers should narrow
97
+ * before using the id to navigate or persist references.
98
+ */
99
+ add: {
100
+ (id: string, data: Omit<T, "id">, options?: UpdateOptions): string | undefined;
101
+ (data: Omit<T, "id">, options?: UpdateOptions): string | undefined;
102
+ };
103
+ /** Remove a document from the collection */
104
+ remove: (id: string, options?: UpdateOptions) => void;
105
+ /** Whether initial data is loading */
106
+ isLoading: boolean;
107
+ /** Whether all changes have synced to Firestore */
108
+ isSynced: boolean;
109
+ /** Whether subscription is active (for lazy collections) */
110
+ isActive: boolean;
111
+ /** Activate a lazy subscription */
112
+ load: () => void;
113
+ /** Force sync pending changes immediately */
114
+ sync: () => Promise<void>;
115
+ /** Error from listener, if any */
116
+ error: Error | undefined;
117
+ /**
118
+ * Firestore collection reference. Undefined when the hook was called with
119
+ * `enabled: false` (no subscription was created).
120
+ */
121
+ ref: CollectionReference<T> | undefined;
122
+ }
123
+ /**
124
+ * An undo/redo action
125
+ */
126
+ interface UndoAction {
127
+ /** Function to undo the change */
128
+ undo: () => Promise<void> | void;
129
+ /** Function to redo the change */
130
+ redo: () => Promise<void> | void;
131
+ /** Optional group ID for batching multiple actions */
132
+ groupId?: string;
133
+ /** Optional path/location context for navigation-aware undo */
134
+ path?: string;
135
+ /** Human-readable description of the action */
136
+ description?: string;
137
+ }
138
+ /**
139
+ * Undo manager state
140
+ */
141
+ interface UndoManagerState {
142
+ /** Stack of actions that can be undone */
143
+ undoStack: readonly UndoAction[];
144
+ /** Stack of actions that can be redone */
145
+ redoStack: readonly UndoAction[];
146
+ /** Whether undo is available */
147
+ canUndo: boolean;
148
+ /** Whether redo is available */
149
+ canRedo: boolean;
150
+ }
151
+ /**
152
+ * Undo manager handle
153
+ */
154
+ interface UndoManager extends UndoManagerState {
155
+ /** Perform undo */
156
+ undo: () => Promise<void>;
157
+ /** Perform redo */
158
+ redo: () => Promise<void>;
159
+ /** Push a new action onto the undo stack */
160
+ push: (action: UndoAction) => void;
161
+ /** Clear all undo/redo history */
162
+ clear: () => void;
163
+ }
164
+ /**
165
+ * Configuration for a document definition.
166
+ *
167
+ * `TData` is the document's TypeScript shape. Provide it explicitly, or let
168
+ * it be inferred from `schema` when using `defineDocument`.
169
+ */
170
+ interface DocumentDefinition<TData extends FirestoreObject> {
171
+ /**
172
+ * Optional Zod schema. When provided, firestate runs `schema.parse(...)`
173
+ * on full-payload writes (`set`, `add`) as a **validation guard** — bad
174
+ * data throws at the call site, not after a Firestore round trip. The
175
+ * parsed result is discarded; firestate stores the caller's original
176
+ * object verbatim. That means schema transforms (`.transform`, `.coerce`,
177
+ * default values) are NOT applied to stored data — do transforms before
178
+ * calling `set`/`add`. Partial `update(diff)` calls are NOT validated
179
+ * because diffs commonly contain Firestore sentinels (`serverTimestamp()`,
180
+ * `arrayUnion`, etc.) that don't satisfy a strict schema.
181
+ */
182
+ schema?: ZodType<TData>;
183
+ /**
184
+ * Collection path. Either a static string (may include multiple `/`-
185
+ * separated segments) or a function that derives the path from route/
186
+ * params. Use the function form when the collection lives under a dynamic
187
+ * parent, e.g. `projects/{projectId}/revisions`.
188
+ */
189
+ collection: string | ((params: Record<string, string>) => string);
190
+ /** Document ID or function to derive it */
191
+ id: string | ((params: Record<string, string>) => string);
192
+ /** Debounce interval for autosave (ms), default 1000 */
193
+ autosave?: number;
194
+ /** Minimum loading indicator time (ms), default 0 */
195
+ minLoadTime?: number;
196
+ /** Whether this document is read-only */
197
+ readOnly?: boolean;
198
+ /** Retry on listener error */
199
+ retryOnError?: boolean;
200
+ /** Retry interval (ms), default 5000 */
201
+ retryInterval?: number;
202
+ }
203
+ /**
204
+ * Configuration for a collection definition.
205
+ *
206
+ * `TData` is the document shape for entries in this collection.
207
+ */
208
+ interface CollectionDefinition<TData extends FirestoreObject> {
209
+ /**
210
+ * Optional Zod schema for documents in the collection. When provided,
211
+ * firestate runs `schema.parse(...)` on full-payload writes (`add`) as
212
+ * a validation guard and stores the caller's original object verbatim.
213
+ * Schema transforms are not applied to stored data — see
214
+ * {@link DocumentDefinition.schema} for the full contract.
215
+ */
216
+ schema?: ZodType<TData>;
217
+ /** Collection path (can include path segments) */
218
+ path: string | ((params: Record<string, string>) => string);
219
+ /** Debounce interval for autosave (ms), default 1000 */
220
+ autosave?: number;
221
+ /** Minimum loading indicator time (ms), default 0 */
222
+ minLoadTime?: number;
223
+ /** Whether this collection is read-only */
224
+ readOnly?: boolean;
225
+ /** Whether to lazy load (only subscribe when load() is called) */
226
+ lazy?: boolean;
227
+ /** Query constraints */
228
+ queryConstraints?: QueryConstraint[];
229
+ /** Retry the snapshot listener on transient errors */
230
+ retryOnError?: boolean;
231
+ /** Retry interval (ms), default 5000 */
232
+ retryInterval?: number;
233
+ }
234
+ /**
235
+ * Configuration for the Firestate store
236
+ */
237
+ interface FirestateConfig {
238
+ /** Firestore instance */
239
+ firestore: Firestore;
240
+ /** Default autosave interval (ms), default 1000 */
241
+ autosave?: number;
242
+ /** Default minimum load time (ms), default 0 */
243
+ minLoadTime?: number;
244
+ /** Maximum undo stack length, default 20 */
245
+ maxUndoLength?: number;
246
+ /** Enable navigation-aware undo/redo */
247
+ enableNavigation?: boolean;
248
+ /** Custom error handler */
249
+ onError?: (error: Error, context: ErrorContext) => void;
250
+ }
251
+ /**
252
+ * Context for error handling
253
+ */
254
+ interface ErrorContext {
255
+ type: "document" | "collection";
256
+ path: string;
257
+ operation: "read" | "write";
258
+ }
259
+ /**
260
+ * Subscriber callback type
261
+ */
262
+ type Subscriber<T> = (state: T) => void;
263
+ /**
264
+ * Unsubscribe function
265
+ */
266
+ type Unsubscribe = () => void;
267
+ //#endregion
268
+ //#region src/schema.d.ts
269
+ /**
270
+ * Define a typed document. `TData` is the document's TypeScript shape.
271
+ *
272
+ * **Most apps should reach for {@link createFirestate} + {@link doc} instead**
273
+ * — that builds a registry of every Firestore thing in one object and
274
+ * generates typed hooks for you. `defineDocument` is the lower-level
275
+ * escape hatch: use it when you need fully custom `collection` / `id`
276
+ * derivation, when you're calling firestate outside React, or when a
277
+ * registry doesn't fit your control flow.
278
+ *
279
+ * Two ways to use:
280
+ *
281
+ * 1. Plain TypeScript type (no schema, no runtime validation):
282
+ * ```ts
283
+ * interface Project { name: string; createdAt: number }
284
+ *
285
+ * const projectDoc = defineDocument<Project>({
286
+ * collection: 'projects',
287
+ * id: (params) => params.projectId,
288
+ * })
289
+ * ```
290
+ *
291
+ * 2. With a Zod schema — `TData` is inferred from `z.infer<S>`. Firestate
292
+ * runs `schema.parse(...)` on full-payload writes (`set`/`add`) so bad
293
+ * data throws at the call site. Partial `update(diff)` calls are not
294
+ * validated (diffs frequently contain Firestore sentinels).
295
+ * ```ts
296
+ * import { z } from 'zod'
297
+ *
298
+ * const ProjectSchema = z.object({ name: z.string(), createdAt: z.number() })
299
+ *
300
+ * const projectDoc = defineDocument({
301
+ * schema: ProjectSchema,
302
+ * collection: 'projects',
303
+ * id: (params) => params.projectId,
304
+ * })
305
+ * ```
306
+ */
307
+ declare function defineDocument<S extends ZodType<FirestoreObject>>(definition: Omit<DocumentDefinition<z.infer<S>>, "schema"> & {
308
+ schema: S;
309
+ }): DocumentDefinition<z.infer<S>>;
310
+ declare function defineDocument<TData extends FirestoreObject>(definition: DocumentDefinition<TData>): DocumentDefinition<TData>;
311
+ /**
312
+ * Define a typed collection. `TData` is the shape of each document in the
313
+ * collection. See {@link defineDocument} for the schema/plain-type tradeoff.
314
+ *
315
+ * **Most apps should reach for {@link createFirestate} + {@link col} instead.**
316
+ * `defineCollection` is the escape hatch for fully custom path derivation
317
+ * or non-React usage.
318
+ *
319
+ * @example
320
+ * ```ts
321
+ * interface Space { name: string; area: number }
322
+ *
323
+ * const spacesCollection = defineCollection<Space>({
324
+ * path: (params) => `projects/${params.projectId}/spaces`,
325
+ * lazy: true,
326
+ * })
327
+ * ```
328
+ */
329
+ declare function defineCollection<S extends ZodType<FirestoreObject>>(definition: Omit<CollectionDefinition<z.infer<S>>, "schema"> & {
330
+ schema: S;
331
+ }): CollectionDefinition<z.infer<S>>;
332
+ declare function defineCollection<TData extends FirestoreObject>(definition: CollectionDefinition<TData>): CollectionDefinition<TData>;
333
+ /**
334
+ * Infer the document data type from a {@link DocumentDefinition}.
335
+ */
336
+ type InferDocumentData<T extends DocumentDefinition<FirestoreObject>> = T extends DocumentDefinition<infer D> ? D : never;
337
+ /**
338
+ * Infer the document data type (with `id` field) from a {@link DocumentDefinition}.
339
+ */
340
+ type InferDocument<T extends DocumentDefinition<FirestoreObject>> = InferDocumentData<T> & {
341
+ id: string;
342
+ };
343
+ /**
344
+ * Infer the document data type from a {@link CollectionDefinition}.
345
+ */
346
+ type InferCollectionData<T extends CollectionDefinition<FirestoreObject>> = T extends CollectionDefinition<infer D> ? D : never;
347
+ /**
348
+ * Infer the document data type (with `id` field) from a {@link CollectionDefinition}.
349
+ */
350
+ type InferCollectionDocument<T extends CollectionDefinition<FirestoreObject>> = InferCollectionData<T> & {
351
+ id: string;
352
+ };
353
+ //#endregion
354
+ //#region src/undo.d.ts
355
+ /**
356
+ * Configuration for creating an undo manager
357
+ */
358
+ interface UndoManagerConfig {
359
+ /** Maximum number of undo actions to keep, default 20 */
360
+ maxLength?: number;
361
+ /** Callback when navigation is requested (for path-aware undo) */
362
+ onNavigate?: (path: string) => void;
363
+ }
364
+ /**
365
+ * Create an undo manager instance.
366
+ * This is a standalone, framework-agnostic implementation.
367
+ *
368
+ * @example
369
+ * ```ts
370
+ * const undoManager = createUndoManager({ maxLength: 10 })
371
+ *
372
+ * undoManager.push({
373
+ * undo: () => restoreOldValue(),
374
+ * redo: () => applyNewValue(),
375
+ * description: 'Update project name',
376
+ * })
377
+ *
378
+ * await undoManager.undo() // Calls restoreOldValue()
379
+ * await undoManager.redo() // Calls applyNewValue()
380
+ * ```
381
+ */
382
+ declare const createUndoManager: (config?: UndoManagerConfig) => UndoManager & {
383
+ subscribe: (fn: Subscriber<UndoManagerState>) => Unsubscribe;
384
+ getState: () => UndoManagerState;
385
+ };
386
+ /**
387
+ * Type for the undo manager with subscription capability
388
+ */
389
+ type UndoManagerWithSubscribe = ReturnType<typeof createUndoManager>;
390
+ //#endregion
391
+ //#region src/store.d.ts
392
+ /**
393
+ * Firestate store that holds configuration and shared state
394
+ */
395
+ interface FirestateStore {
396
+ /** Firestore instance */
397
+ readonly firestore: Firestore;
398
+ /** Undo manager instance */
399
+ readonly undoManager: UndoManagerWithSubscribe;
400
+ /** Default autosave interval (ms) */
401
+ readonly autosave: number;
402
+ /** Default minimum load time (ms) */
403
+ readonly minLoadTime: number;
404
+ /** Report an error */
405
+ reportError: (error: Error, context: ErrorContext) => void;
406
+ /**
407
+ * Replace the error handler at runtime. Used by FirestateProvider to keep
408
+ * the store identity stable when consumers pass an inline `onError`
409
+ * callback that changes reference on every render.
410
+ */
411
+ setOnError: (handler?: (error: Error, context: ErrorContext) => void) => void;
412
+ /** Subscribe to sync state changes */
413
+ subscribeToSyncState: (fn: Subscriber<boolean>) => Unsubscribe;
414
+ /** Report a document/collection sync state change */
415
+ reportSyncState: (key: string, isSynced: boolean) => void;
416
+ /**
417
+ * Remove a sync-state key. Subscriptions call this on stop() so an
418
+ * unmounted hook does not leave the global isSynced stuck at false.
419
+ */
420
+ unregisterSyncState: (key: string) => void;
421
+ /** Get whether all tracked resources are synced */
422
+ readonly isSynced: boolean;
423
+ }
424
+ /**
425
+ * Create a Firestate store.
426
+ * This is the central configuration point for your Firestore state management.
427
+ *
428
+ * @example
429
+ * ```ts
430
+ * import { createStore } from 'firestate'
431
+ * import { db } from './firebase'
432
+ *
433
+ * export const store = createStore({
434
+ * firestore: db,
435
+ * autosave: 1000,
436
+ * maxUndoLength: 20,
437
+ * onError: (error, context) => {
438
+ * console.error(`Error in ${context.type} ${context.path}:`, error)
439
+ * },
440
+ * })
441
+ * ```
442
+ */
443
+ declare const createStore: (config: FirestateConfig) => FirestateStore;
444
+ /**
445
+ * Type alias for the store type
446
+ */
447
+ type Store = ReturnType<typeof createStore>;
448
+ //#endregion
449
+ //#region src/hooks.d.ts
450
+ /**
451
+ * Context for providing the Firestate store
452
+ */
453
+ declare const FirestateContext: react0.Context<FirestateStore | null>;
454
+ /**
455
+ * Hook to access the Firestate store
456
+ */
457
+ declare const useStore: () => FirestateStore;
458
+ /**
459
+ * Hook to access the undo manager
460
+ */
461
+ declare const useUndoManager: () => UndoManager;
462
+ /**
463
+ * Hook to check if all tracked resources are synced
464
+ */
465
+ declare const useIsSynced: () => boolean;
466
+ /**
467
+ * Options for useDocument hook
468
+ */
469
+ interface UseDocumentOptions<TData extends FirestoreObject> {
470
+ /** Document definition from defineDocument() */
471
+ definition: DocumentDefinition<TData>;
472
+ /** Route/path parameters for dynamic paths */
473
+ params?: Record<string, string>;
474
+ /** Override read-only setting */
475
+ readOnly?: boolean;
476
+ /** Enable undo/redo for this document (default: true) */
477
+ undoable?: boolean;
478
+ /**
479
+ * If false, no subscription is created and a no-op handle is returned
480
+ * (`{ data: undefined, isLoading: false, isSynced: true, ref: undefined }`).
481
+ * Use this to gate subscriptions on route params that aren't ready yet.
482
+ * Default: true.
483
+ */
484
+ enabled?: boolean;
485
+ }
486
+ /**
487
+ * Hook to subscribe to a Firestore document with real-time updates.
488
+ *
489
+ * The subscription is keyed on the resolved document path (`definition` +
490
+ * computed id) and `readOnly`. When that key changes — typically because
491
+ * `params` produces a different id — the hook tears down the old Firestore
492
+ * listener and attaches a new one. Toggling `undoable` does not rebuild the
493
+ * subscription.
494
+ *
495
+ * Use `enabled: false` to suppress the subscription entirely (e.g., when
496
+ * route params aren't ready yet).
497
+ *
498
+ * **SSR.** On the server there is no Firestore listener, so this hook returns
499
+ * the initial handle (`{ data: undefined, isLoading: true }`). Mutations like
500
+ * `update`/`set` will mutate orphaned local state with no effect — avoid
501
+ * calling them server-side.
502
+ *
503
+ * @example
504
+ * ```tsx
505
+ * const projectDoc = defineDocument<Project>({
506
+ * collection: 'projects',
507
+ * id: (params) => params.projectId,
508
+ * })
509
+ *
510
+ * function ProjectEditor({ projectId }: { projectId: string }) {
511
+ * const { data, update, isLoading, isSynced } = useDocument({
512
+ * definition: projectDoc,
513
+ * params: { projectId },
514
+ * })
515
+ *
516
+ * if (isLoading) return <Spinner />
517
+ *
518
+ * return (
519
+ * <input
520
+ * value={data?.name ?? ''}
521
+ * onChange={(e) => update({ name: e.target.value })}
522
+ * />
523
+ * )
524
+ * }
525
+ * ```
526
+ */
527
+ declare const useDocument: <TData extends FirestoreObject>(options: UseDocumentOptions<TData>) => DocumentHandle<TData>;
528
+ /**
529
+ * Options for useCollection hook
530
+ */
531
+ interface UseCollectionOptions<TData extends FirestoreObject> {
532
+ /** Collection definition from defineCollection() */
533
+ definition: CollectionDefinition<TData>;
534
+ /** Route/path parameters for dynamic paths */
535
+ params?: Record<string, string>;
536
+ /** Override read-only setting */
537
+ readOnly?: boolean;
538
+ /** Additional query constraints */
539
+ queryConstraints?: QueryConstraint[];
540
+ /** Enable undo/redo for this collection (default: true) */
541
+ undoable?: boolean;
542
+ /**
543
+ * If false, no subscription is created and a no-op handle is returned
544
+ * (`{ data: {}, isLoading: false, isActive: false }`). Use this to gate on
545
+ * route params that aren't ready yet. Default: true.
546
+ */
547
+ enabled?: boolean;
548
+ }
549
+ /**
550
+ * Hook to subscribe to a Firestore collection with real-time updates.
551
+ *
552
+ * The subscription is keyed on the resolved collection path, `readOnly`, and
553
+ * the `queryConstraints` reference. When any of these change, the listener
554
+ * is torn down and re-attached with the new query. Toggling `undoable` does
555
+ * not rebuild the subscription.
556
+ *
557
+ * **Memoize `queryConstraints`.** An inline array (`queryConstraints={[where(...)]}`)
558
+ * creates a new reference every render, which will thrash the listener.
559
+ * Wrap in `useMemo` with the underlying filter values as deps.
560
+ *
561
+ * Use `enabled: false` to suppress the subscription entirely (e.g., when
562
+ * route params aren't ready yet).
563
+ *
564
+ * **SSR.** On the server there is no Firestore listener, so this hook returns
565
+ * the initial handle (`{ data: {}, isLoading: true }` for non-lazy, or
566
+ * `isActive: false` for lazy). Avoid calling mutations server-side.
567
+ *
568
+ * @example
569
+ * ```tsx
570
+ * const spacesCollection = defineCollection<Space>({
571
+ * path: (params) => `projects/${params.projectId}/spaces`,
572
+ * lazy: true,
573
+ * })
574
+ *
575
+ * function SpacesList({ projectId }: { projectId: string }) {
576
+ * const { data, update, load, isActive, isLoading } = useCollection({
577
+ * definition: spacesCollection,
578
+ * params: { projectId },
579
+ * })
580
+ *
581
+ * // Lazy load on mount
582
+ * useEffect(() => { load() }, [load])
583
+ *
584
+ * if (!isActive) return <Button onClick={load}>Load Spaces</Button>
585
+ * if (isLoading) return <Spinner />
586
+ *
587
+ * return (
588
+ * <ul>
589
+ * {Object.values(data).map((space) => (
590
+ * <li key={space.id}>{space.name}</li>
591
+ * ))}
592
+ * </ul>
593
+ * )
594
+ * }
595
+ * ```
596
+ */
597
+ declare const useCollection: <TData extends FirestoreObject>(options: UseCollectionOptions<TData>) => CollectionHandle<TData>;
598
+ /**
599
+ * Keyboard shortcut hook for undo/redo
600
+ *
601
+ * @example
602
+ * ```tsx
603
+ * function App() {
604
+ * useUndoKeyboardShortcuts()
605
+ * return <YourApp />
606
+ * }
607
+ * ```
608
+ */
609
+ declare const useUndoKeyboardShortcuts: () => void;
610
+ //#endregion
611
+ //#region src/firestate.d.ts
612
+ /**
613
+ * Knobs forwarded from a generated document hook to {@link useDocument}.
614
+ * Same shape as `UseDocumentOptions` minus the fields the registry already
615
+ * owns (`definition`, `params`).
616
+ */
617
+ type DocHookOptions<T extends FirestoreObject> = Omit<UseDocumentOptions<T>, "definition" | "params">;
618
+ /**
619
+ * Knobs forwarded from a generated collection hook to {@link useCollection}.
620
+ */
621
+ type ColHookOptions<T extends FirestoreObject> = Omit<UseCollectionOptions<T>, "definition" | "params">;
622
+ interface CommonEntryOptions {
623
+ /** Debounce interval for autosave (ms). */
624
+ autosave?: number;
625
+ /** Minimum loading indicator time (ms). */
626
+ minLoadTime?: number;
627
+ /** Whether this entry is read-only. */
628
+ readOnly?: boolean;
629
+ /** Retry the snapshot listener on transient errors. */
630
+ retryOnError?: boolean;
631
+ /** Retry interval (ms). */
632
+ retryInterval?: number;
633
+ }
634
+ /**
635
+ * Document entry in a Firestate registry. Produced by {@link doc}.
636
+ *
637
+ * The `P` generic carries the path template's string-literal type so the
638
+ * generated hook can type-check param keys. `__kind` is a runtime
639
+ * discriminator; `__type` is a phantom field used purely for inference at
640
+ * the call site and is never read.
641
+ */
642
+ interface DocEntry<T extends FirestoreObject, P extends string = string> extends CommonEntryOptions {
643
+ readonly __kind: "document";
644
+ readonly __type?: T;
645
+ /** Path template, e.g. `'taskLists/{listId}'`. */
646
+ path: P;
647
+ /**
648
+ * Zod schema. **Required** — firestate's registry API is opinionated
649
+ * about Zod. The schema is the source of `T` for the generated hooks
650
+ * via `z.infer`, and firestate runs `schema.parse(...)` on full-payload
651
+ * writes (`set`/`add`) so bad data throws at the call site rather than
652
+ * after a Firestore round trip. Partial `update(diff)` is NOT validated
653
+ * (diffs frequently contain Firestore sentinels like `serverTimestamp()`).
654
+ *
655
+ * If you don't want a schema at all, use {@link defineDocument} directly —
656
+ * the escape hatch keeps the plain-TypeScript form at the cost of looser
657
+ * param typing and no runtime validation.
658
+ */
659
+ schema: ZodType<T>;
660
+ }
661
+ /** Collection entry in a Firestate registry. Produced by {@link col}. */
662
+ interface ColEntry<T extends FirestoreObject, P extends string = string> extends CommonEntryOptions {
663
+ readonly __kind: "collection";
664
+ readonly __type?: T;
665
+ /** Path template, e.g. `'taskLists/{listId}/tasks'`. */
666
+ path: P;
667
+ /** Zod schema. Required. See {@link DocEntry.schema}. */
668
+ schema: ZodType<T>;
669
+ /** Only subscribe when `load()` is called. */
670
+ lazy?: boolean;
671
+ /** Additional Firestore query constraints. */
672
+ queryConstraints?: QueryConstraint[];
673
+ }
674
+ type FirestateEntry<T extends FirestoreObject = FirestoreObject, P extends string = string> = DocEntry<T, P> | ColEntry<T, P>;
675
+ type FirestateRegistry = Record<string, FirestateEntry<any, any>>;
676
+ /**
677
+ * Extract `{name}` placeholders from a path template into a params shape.
678
+ *
679
+ * - `'users'` → `{}`
680
+ * - `'users/{userId}'` → `{ userId: string }`
681
+ * - `'projects/{projectId}/revisions/{revisionId}'` → `{ projectId: string; revisionId: string }`
682
+ *
683
+ * When the path is widened to `string` (no literal preserved), we fall
684
+ * back to `Record<string, string>` so existing call sites keep compiling.
685
+ */
686
+ type ParamsOf<P extends string> = string extends P ? Record<string, string> : Prettify<RawParamsOf<P>>;
687
+ type RawParamsOf<P extends string> = P extends `${string}{${infer K}}${infer Rest}` ? { [Key in K]: string } & RawParamsOf<Rest> : {};
688
+ type Prettify<T> = { [K in keyof T]: T[K] } & {};
689
+ type DocOpts<T extends FirestoreObject> = Omit<DocEntry<T>, "__kind" | "__type" | "path">;
690
+ type ColOpts<T extends FirestoreObject> = Omit<ColEntry<T>, "__kind" | "__type" | "path">;
691
+ /**
692
+ * Declare a single-document entry for a Firestate registry.
693
+ *
694
+ * **A Zod `schema` field is required.** Both the data type (`T`) and the
695
+ * path's literal type (`P`) are inferred from the call — `T` via
696
+ * `z.infer<S>`, `P` from `path` — so the generated hook can statically
697
+ * type-check the params object the caller passes. The schema also runs
698
+ * at runtime on full-payload writes (`set`/`add`).
699
+ *
700
+ * If you'd rather not provide a schema at all, use {@link defineDocument}
701
+ * directly — that escape hatch keeps the plain-TypeScript form, at the
702
+ * cost of looser param typing on the hook and no runtime validation.
703
+ *
704
+ * ```ts
705
+ * import { z } from 'zod'
706
+ *
707
+ * const TaskListSchema = z.object({ name: z.string(), createdAt: z.number() })
708
+ * doc({ path: 'taskLists/{listId}', schema: TaskListSchema })
709
+ * // → DocEntry<{ name: string; createdAt: number }, 'taskLists/{listId}'>
710
+ * ```
711
+ */
712
+ declare function doc<S extends ZodType<FirestoreObject>, const P extends string = string>(opts: Omit<DocOpts<z.infer<S>>, "schema"> & {
713
+ schema: S;
714
+ path: P;
715
+ }): DocEntry<z.infer<S>, P>;
716
+ /**
717
+ * Declare a collection entry for a Firestate registry. See {@link doc}
718
+ * for the schema/typing contract.
719
+ */
720
+ declare function col<S extends ZodType<FirestoreObject>, const P extends string = string>(opts: Omit<ColOpts<z.infer<S>>, "schema"> & {
721
+ schema: S;
722
+ path: P;
723
+ }): ColEntry<z.infer<S>, P>;
724
+ type HookName<K extends string> = `use${Capitalize<K>}`;
725
+ type HookFor<E> = E extends DocEntry<infer T, infer P> ? keyof ParamsOf<P> extends never ? (params?: Record<string, string>, options?: DocHookOptions<T>) => DocumentHandle<T> : (params: ParamsOf<P>, options?: DocHookOptions<T>) => DocumentHandle<T> : E extends ColEntry<infer T, infer P> ? keyof ParamsOf<P> extends never ? (params?: Record<string, string>, options?: ColHookOptions<T>) => CollectionHandle<T> : (params: ParamsOf<P>, options?: ColHookOptions<T>) => CollectionHandle<T> : never;
726
+ type FirestateApi<R extends FirestateRegistry> = { [K in keyof R & string as HookName<K>]: HookFor<R[K]> };
727
+ /**
728
+ * Turn a Firestate registry into a map of typed React hooks. Each entry
729
+ * `K` produces a hook named `use{Capitalize<K>}`.
730
+ *
731
+ * ```ts
732
+ * export const { useTaskList, useTasks } = createFirestate({
733
+ * taskList: doc<TaskList>('taskLists/{listId}'),
734
+ * tasks: col<Task>('taskLists/{listId}/tasks'),
735
+ * })
736
+ * ```
737
+ */
738
+ declare function createFirestate<R extends FirestateRegistry>(registry: R): FirestateApi<R>;
739
+ //#endregion
740
+ //#region src/diff.d.ts
741
+ /**
742
+ * Check if two values are deeply equal
743
+ */
744
+ declare const isDeepEqual: (a: unknown, b: unknown) => boolean;
745
+ /**
746
+ * Compute the minimal diff between two objects for Firestore updates.
747
+ * Returns only the fields that changed, using deleteField() for removed fields.
748
+ *
749
+ * @param from - The original object (sync state)
750
+ * @param to - The target object (local state)
751
+ * @returns A partial object containing only changed fields
752
+ */
753
+ declare const computeDiff: <T extends FirestoreObject>(from: T, to: T | undefined) => WithFieldValue<DeepPartial<T>>;
754
+ /**
755
+ * Apply a Firestore diff to a target object in place (mutating).
756
+ * Handles deleteField(), serverTimestamp(), and nested objects.
757
+ *
758
+ * Most code should use `applyDiff` (immutable) instead.
759
+ * This mutable version is useful for performance-critical paths
760
+ * where you're already working with a cloned object.
761
+ *
762
+ * @param target - The object to mutate
763
+ * @param diff - The diff to apply
764
+ */
765
+ declare const applyDiffMutable: (target: FirestoreObject, diff: Record<string, unknown>) => void;
766
+ /**
767
+ * Create a deep clone of an object that's safe for Firestore operations.
768
+ *
769
+ * Firestore opaque values (FieldValue sentinels, Timestamp,
770
+ * DocumentReference, GeoPoint, Bytes, VectorValue) are returned **by
771
+ * reference**. They are immutable from the user's perspective; cloning
772
+ * them by walking keys would either lose their prototype — turning a
773
+ * `DocumentReference` into a plain object Firestore can't recognize —
774
+ * or destroy a sentinel that needed to reach the server intact.
775
+ */
776
+ declare const deepClone: <T>(value: T) => T;
777
+ /**
778
+ * Check if a diff is empty (no changes)
779
+ */
780
+ declare const isDiffEmpty: (diff: Record<string, unknown>) => boolean;
781
+ /**
782
+ * Flatten a nested diff object to dot notation for use with Firestore's updateDoc.
783
+ *
784
+ * This converts:
785
+ * ```
786
+ * { building: { floors: 5, height: 100 }, name: 'Test' }
787
+ * ```
788
+ * To:
789
+ * ```
790
+ * { 'building.floors': 5, 'building.height': 100, 'name': 'Test' }
791
+ * ```
792
+ *
793
+ * Arrays, FieldValue sentinels (deleteField, serverTimestamp, …) and
794
+ * Firestore value types (Timestamp, DocumentReference, GeoPoint, Bytes,
795
+ * VectorValue) are NOT flattened — they're preserved at their path so
796
+ * Firestore receives them in their original form.
797
+ *
798
+ * @param diff - The nested diff object
799
+ * @param prefix - Internal: current path prefix for recursion
800
+ * @returns Flattened object with dotted keys
801
+ */
802
+ declare const flattenDiff: (diff: Record<string, unknown>, prefix?: string) => Record<string, unknown>;
803
+ /**
804
+ * Merge two diffs together, with the second taking precedence
805
+ */
806
+ declare const mergeDiffs: <T extends FirestoreObject>(first: WithFieldValue<DeepPartial<T>>, second: WithFieldValue<DeepPartial<T>>) => WithFieldValue<DeepPartial<T>>;
807
+ /**
808
+ * Apply a diff to an object, returning a new object.
809
+ * The original object is not modified.
810
+ *
811
+ * @example
812
+ * ```ts
813
+ * const original = { name: 'Project', count: 5 }
814
+ * const diff = { name: 'Updated', count: deleteField() }
815
+ * const result = applyDiff(original, diff)
816
+ * // result = { name: 'Updated' }
817
+ * // original is unchanged
818
+ * ```
819
+ */
820
+ declare const applyDiff: <T extends FirestoreObject>(state: T, diff: WithFieldValue<DeepPartial<T>>) => T;
821
+ /**
822
+ * Compute the undo diff that would reverse the effect of applying a diff to a state.
823
+ *
824
+ * Given a starting state and a diff that was (or will be) applied to it,
825
+ * returns a new diff that when applied to the result would restore the original state.
826
+ *
827
+ * @example
828
+ * ```ts
829
+ * const startState = { name: 'Foo', count: 5 }
830
+ * const diff = { name: 'Bar', count: deleteField() }
831
+ *
832
+ * // Apply the diff
833
+ * const endState = applyDiff(startState, diff)
834
+ * // endState = { name: 'Bar' }
835
+ *
836
+ * // Compute the undo
837
+ * const undoDiff = computeUndoDiff(startState, diff)
838
+ * // undoDiff = { name: 'Foo', count: 5 }
839
+ *
840
+ * // Applying undoDiff to endState restores startState
841
+ * const restored = applyDiff(endState, undoDiff)
842
+ * // restored = { name: 'Foo', count: 5 }
843
+ * ```
844
+ */
845
+ declare const computeUndoDiff: <T extends FirestoreObject>(startState: T, diff: WithFieldValue<DeepPartial<T>>) => WithFieldValue<DeepPartial<T>>;
846
+ /**
847
+ * Check if a diff affects a specific path (supports dot notation).
848
+ *
849
+ * @example
850
+ * ```ts
851
+ * const diff = { building: { floors: 5 }, name: 'Test' }
852
+ *
853
+ * diffContainsPath(diff, 'name') // true
854
+ * diffContainsPath(diff, 'building') // true
855
+ * diffContainsPath(diff, 'building.floors') // true
856
+ * diffContainsPath(diff, 'building.height') // false
857
+ * diffContainsPath(diff, 'other') // false
858
+ * ```
859
+ */
860
+ declare const diffContainsPath: (diff: Record<string, unknown>, path: string) => boolean;
861
+ /**
862
+ * Extract the value at a specific path from a diff (supports dot notation).
863
+ * Returns undefined if the path doesn't exist in the diff.
864
+ *
865
+ * @example
866
+ * ```ts
867
+ * const diff = { building: { floors: 5, height: 100 }, name: 'Test' }
868
+ *
869
+ * extractDiffValue(diff, 'name') // 'Test'
870
+ * extractDiffValue(diff, 'building') // { floors: 5, height: 100 }
871
+ * extractDiffValue(diff, 'building.floors') // 5
872
+ * extractDiffValue(diff, 'building.missing') // undefined
873
+ * ```
874
+ */
875
+ declare const extractDiffValue: (diff: Record<string, unknown>, path: string) => unknown;
876
+ /**
877
+ * Create a diff that sets a value at a specific path (supports dot notation).
878
+ *
879
+ * @example
880
+ * ```ts
881
+ * createDiffAtPath('name', 'New Name')
882
+ * // { name: 'New Name' }
883
+ *
884
+ * createDiffAtPath('building.floors', 5)
885
+ * // { building: { floors: 5 } }
886
+ *
887
+ * createDiffAtPath('building.config.enabled', true)
888
+ * // { building: { config: { enabled: true } } }
889
+ * ```
890
+ */
891
+ declare const createDiffAtPath: (path: string, value: unknown) => Record<string, unknown>;
892
+ /**
893
+ * Invert a flattened diff back to nested object structure.
894
+ * Opposite of flattenDiff.
895
+ *
896
+ * @example
897
+ * ```ts
898
+ * const flat = { 'building.floors': 5, 'building.height': 100, 'name': 'Test' }
899
+ * const nested = unflattenDiff(flat)
900
+ * // { building: { floors: 5, height: 100 }, name: 'Test' }
901
+ * ```
902
+ */
903
+ declare const unflattenDiff: (flatDiff: Record<string, unknown>) => Record<string, unknown>;
904
+ //#endregion
905
+ //#region src/document.d.ts
906
+ /**
907
+ * Options for creating a document subscription
908
+ */
909
+ interface DocumentOptions<TData extends FirestoreObject> {
910
+ /** The store instance */
911
+ store: FirestateStore;
912
+ /** Document definition from defineDocument() */
913
+ definition: DocumentDefinition<TData>;
914
+ /**
915
+ * Resolved document id. If omitted and `definition.id` is a string, that
916
+ * value is used. If `definition.id` is a function, this option is required.
917
+ */
918
+ docId?: string;
919
+ /**
920
+ * Resolved collection path. If omitted and `definition.collection` is a
921
+ * string, that value is used. If `definition.collection` is a function,
922
+ * this option is required.
923
+ */
924
+ collectionPath?: string;
925
+ /** Override read-only setting */
926
+ readOnly?: boolean;
927
+ /** Callback for pushing undo actions */
928
+ onPushUndo?: (undoAction: () => void, redoAction: () => void, options?: UpdateOptions) => void;
929
+ }
930
+ /**
931
+ * Create a document subscription.
932
+ * This is a low-level API - prefer using useDocument hook in React.
933
+ *
934
+ * @example
935
+ * ```ts
936
+ * const subscription = createDocumentSubscription({
937
+ * store,
938
+ * definition: projectDoc,
939
+ * docId: '123',
940
+ * })
941
+ *
942
+ * const unsubscribe = subscription.subscribe((state) => {
943
+ * console.log('Document state:', state)
944
+ * })
945
+ *
946
+ * subscription.load()
947
+ * ```
948
+ */
949
+ declare const createDocumentSubscription: <TData extends FirestoreObject>(options: DocumentOptions<TData>) => {
950
+ /** Attach the Firestore listener */load: () => void; /** Stop the Firestore listener */
951
+ stop: () => void; /** Subscribe to state changes */
952
+ subscribe: (fn: Subscriber<DocumentState<TData>>) => Unsubscribe; /** Get current state */
953
+ getState: () => DocumentState<TData>; /** Get document handle for updates */
954
+ getHandle: () => DocumentHandle<TData>; /** Force sync now */
955
+ sync: () => Promise<void>;
956
+ };
957
+ //#endregion
958
+ //#region src/collection.d.ts
959
+ /**
960
+ * Options for creating a collection subscription
961
+ */
962
+ interface CollectionOptions<TData extends FirestoreObject> {
963
+ /** The store instance */
964
+ store: FirestateStore;
965
+ /** Collection definition from defineCollection() */
966
+ definition: CollectionDefinition<TData>;
967
+ /**
968
+ * Resolved collection path. If omitted and `definition.path` is a string,
969
+ * that value is used. If `definition.path` is a function, this option is
970
+ * required.
971
+ */
972
+ collectionPath?: string;
973
+ /** Override read-only setting */
974
+ readOnly?: boolean;
975
+ /** Additional query constraints */
976
+ queryConstraints?: QueryConstraint[];
977
+ /** Callback for pushing undo actions */
978
+ onPushUndo?: (undoAction: () => void, redoAction: () => void, options?: UpdateOptions) => void;
979
+ }
980
+ /**
981
+ * Create a collection subscription.
982
+ * This is a low-level API - prefer using useCollection hook in React.
983
+ *
984
+ * @example
985
+ * ```ts
986
+ * const subscription = createCollectionSubscription({
987
+ * store,
988
+ * definition: spacesCollection,
989
+ * collectionPath: 'projects/123/spaces',
990
+ * })
991
+ *
992
+ * const unsubscribe = subscription.subscribe((state) => {
993
+ * console.log('Collection state:', state)
994
+ * })
995
+ *
996
+ * subscription.load() // For lazy collections
997
+ * ```
998
+ */
999
+ declare const createCollectionSubscription: <TData extends FirestoreObject>(options: CollectionOptions<TData>) => {
1000
+ /** Activate the subscription (for lazy loading) */load: () => void; /** Stop the Firestore listener */
1001
+ stop: () => void; /** Subscribe to state changes */
1002
+ subscribe: (fn: Subscriber<CollectionState<TData>>) => Unsubscribe; /** Get current state */
1003
+ getState: () => CollectionState<TData>; /** Get collection handle for updates */
1004
+ getHandle: () => CollectionHandle<TData>; /** Force sync now */
1005
+ sync: () => Promise<void>;
1006
+ };
1007
+ //#endregion
1008
+ //#region src/provider.d.ts
1009
+ /**
1010
+ * Props for FirestateProvider
1011
+ */
1012
+ interface FirestateProviderProps {
1013
+ /** Firestore instance */
1014
+ firestore: Firestore;
1015
+ /** Default autosave interval (ms), default 1000 */
1016
+ autosave?: number;
1017
+ /** Default minimum load time (ms), default 0 */
1018
+ minLoadTime?: number;
1019
+ /** Maximum undo stack length, default 20 */
1020
+ maxUndoLength?: number;
1021
+ /** Custom error handler */
1022
+ onError?: (error: Error, context: ErrorContext) => void;
1023
+ /** React children */
1024
+ children: React.ReactNode;
1025
+ }
1026
+ /**
1027
+ * Provider component that sets up Firestate for your application.
1028
+ *
1029
+ * @example
1030
+ * ```tsx
1031
+ * import { FirestateProvider } from 'firestate'
1032
+ * import { db } from './firebase'
1033
+ *
1034
+ * function App() {
1035
+ * return (
1036
+ * <FirestateProvider
1037
+ * firestore={db}
1038
+ * autosave={1000}
1039
+ * maxUndoLength={20}
1040
+ * onError={(error, ctx) => console.error(ctx.path, error)}
1041
+ * >
1042
+ * <YourApp />
1043
+ * </FirestateProvider>
1044
+ * )
1045
+ * }
1046
+ * ```
1047
+ */
1048
+ declare const FirestateProvider: React.FC<FirestateProviderProps>;
1049
+ /**
1050
+ * Props for using an existing store
1051
+ */
1052
+ interface FirestateStoreProviderProps {
1053
+ /** Pre-created store instance */
1054
+ store: FirestateStore;
1055
+ /** React children */
1056
+ children: React.ReactNode;
1057
+ }
1058
+ /**
1059
+ * Provider that uses an existing store instance.
1060
+ * Useful when you need to create the store outside of React.
1061
+ *
1062
+ * @example
1063
+ * ```tsx
1064
+ * const store = createStore({ firestore: db })
1065
+ *
1066
+ * function App() {
1067
+ * return (
1068
+ * <FirestateStoreProvider store={store}>
1069
+ * <YourApp />
1070
+ * </FirestateStoreProvider>
1071
+ * )
1072
+ * }
1073
+ * ```
1074
+ */
1075
+ declare const FirestateStoreProvider: React.FC<FirestateStoreProviderProps>;
1076
+ /**
1077
+ * Hook to use navigation blocker when there are unsaved changes.
1078
+ * Works with react-router or similar routers.
1079
+ *
1080
+ * @example
1081
+ * ```tsx
1082
+ * function ProjectPage() {
1083
+ * const shouldBlock = useUnsavedChangesBlocker()
1084
+ *
1085
+ * // Use with react-router's useBlocker
1086
+ * const blocker = useBlocker(
1087
+ * ({ currentLocation, nextLocation }) =>
1088
+ * currentLocation.pathname !== nextLocation.pathname && shouldBlock
1089
+ * )
1090
+ *
1091
+ * return (
1092
+ * <>
1093
+ * <ProjectEditor />
1094
+ * {blocker.state === 'blocked' && (
1095
+ * <Dialog>Your changes may not be saved!</Dialog>
1096
+ * )}
1097
+ * </>
1098
+ * )
1099
+ * }
1100
+ * ```
1101
+ */
1102
+ declare const useUnsavedChangesBlocker: () => boolean;
1103
+ //#endregion
1104
+ export { type ColEntry, type CollectionDefinition, type CollectionHandle, type CollectionOptions, type CollectionState, type DeepPartial, type DocEntry, type DocumentDefinition, type DocumentHandle, type DocumentOptions, type DocumentState, type ErrorContext, type FirestateApi, type FirestateConfig, FirestateContext, type FirestateEntry, FirestateProvider, type FirestateProviderProps, type FirestateRegistry, type FirestateStore, FirestateStoreProvider, type FirestateStoreProviderProps, type FirestoreObject, type InferCollectionData, type InferCollectionDocument, type InferDocument, type InferDocumentData, type Store, type UndoAction, type UndoManager, type UndoManagerConfig, type UndoManagerState, type UndoManagerWithSubscribe, type UpdateOptions, type UseCollectionOptions, type UseDocumentOptions, applyDiff, applyDiffMutable, col, computeDiff, computeUndoDiff, createCollectionSubscription, createDiffAtPath, createDocumentSubscription, createFirestate, createStore, createUndoManager, deepClone, defineCollection, defineDocument, diffContainsPath, doc, extractDiffValue, flattenDiff, isDeepEqual, isDiffEmpty, mergeDiffs, unflattenDiff, useCollection, useDocument, useIsSynced, useStore, useUndoKeyboardShortcuts, useUndoManager, useUnsavedChangesBlocker };
1105
+ //# sourceMappingURL=index.d.mts.map