@hvakr/firestate 0.1.1 → 0.1.3

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/dist/index.d.mts CHANGED
@@ -120,6 +120,46 @@ interface CollectionHandle<T extends FirestoreObject> {
120
120
  */
121
121
  ref: CollectionReference<T> | undefined;
122
122
  }
123
+ /** Reactive status fields a selector drops unless it folds them into its slice. */
124
+ type DocumentStatusKeys = "isLoading" | "isSynced" | "error";
125
+ type CollectionStatusKeys = DocumentStatusKeys | "isActive";
126
+ /**
127
+ * A {@link DocumentHandle} reduced to a hook-level `selector`'s output. The
128
+ * selector receives the full observable state ({@link DocumentState}) and
129
+ * returns the slice this component reacts to; the handle re-renders *only* when
130
+ * that slice changes.
131
+ *
132
+ * A selected handle deliberately exposes **only** `data` (the slice) plus the
133
+ * writer surface (`update`/`set`/`delete`/`sync`) and `ref` — never the status
134
+ * fields (`isLoading`/`isSynced`/`error`). Status is not a freebie here: if a
135
+ * component needs it, it must select it (`s => ({ slice: s.data?.x, loading:
136
+ * s.isLoading })`), so what you re-render on is exactly what you select. The
137
+ * writers stay typed against the full document `TData`, because a selector
138
+ * changes what you *read*, never what you *write*.
139
+ *
140
+ * Note: `update(diff)` takes a *partial* of the full document and merges it, so
141
+ * writing a selected field is `update({ field: next })`. `set(data)` still
142
+ * *replaces the entire document*, not the slice — never pass the selected value
143
+ * to `set`, or you will overwrite every other field. Prefer `update` from a
144
+ * narrowed handle; reach for `set` only when you hold the full document.
145
+ */
146
+ interface SelectedDocumentHandle<TData extends FirestoreObject, TSelected> extends Omit<DocumentHandle<TData>, "data" | DocumentStatusKeys> {
147
+ /** The slice produced by the hook's `selector`. */
148
+ data: TSelected;
149
+ }
150
+ /**
151
+ * A {@link CollectionHandle} reduced to a hook-level `selector`'s output. As
152
+ * with {@link SelectedDocumentHandle}, the selector receives the full
153
+ * observable state ({@link CollectionState}) and the handle exposes only the
154
+ * slice plus the writer surface (`update`/`add`/`remove`/`load`/`sync`) and
155
+ * `ref` — status fields (`isLoading`/`isSynced`/`isActive`/`error`) are dropped
156
+ * unless folded into the slice. Writers stay typed against the full collection
157
+ * of `TData`.
158
+ */
159
+ interface SelectedCollectionHandle<TData extends FirestoreObject, TSelected> extends Omit<CollectionHandle<TData>, "data" | CollectionStatusKeys> {
160
+ /** The slice produced by the hook's `selector`. */
161
+ data: TSelected;
162
+ }
123
163
  /**
124
164
  * An undo/redo action
125
165
  */
@@ -269,7 +309,7 @@ type Subscriber<T> = (state: T) => void;
269
309
  */
270
310
  type Unsubscribe = () => void;
271
311
  //#endregion
272
- //#region src/schema.d.ts
312
+ //#region src/registry/schema.d.ts
273
313
  /**
274
314
  * Define a typed document. `TData` is the document's TypeScript shape.
275
315
  *
@@ -355,7 +395,7 @@ type InferCollectionDocument<T extends CollectionDefinition<FirestoreObject>> =
355
395
  id: string;
356
396
  };
357
397
  //#endregion
358
- //#region src/undo.d.ts
398
+ //#region src/utils/undo.d.ts
359
399
  /**
360
400
  * Configuration for creating an undo manager
361
401
  */
@@ -392,7 +432,7 @@ declare const createUndoManager: (config?: UndoManagerConfig) => UndoManager & {
392
432
  */
393
433
  type UndoManagerWithSubscribe = ReturnType<typeof createUndoManager>;
394
434
  //#endregion
395
- //#region src/store.d.ts
435
+ //#region src/core/store.d.ts
396
436
  /**
397
437
  * Firestate store that holds configuration and shared state
398
438
  */
@@ -456,7 +496,63 @@ declare const createStore: (config: FirestateConfig) => FirestateStore;
456
496
  */
457
497
  type Store = ReturnType<typeof createStore>;
458
498
  //#endregion
459
- //#region src/hooks.d.ts
499
+ //#region src/react/hooks.d.ts
500
+ /**
501
+ * Opts a {@link useDocument} call into a selected slice. The hook still returns
502
+ * a full handle (writers, `ref`, status) — only `data` is narrowed to whatever
503
+ * `selector` returns.
504
+ */
505
+ interface DocumentSelectorOptions<TData extends FirestoreObject, TSelected> {
506
+ /**
507
+ * Project the document's observable state down to the slice this component
508
+ * reacts to. The selector receives the full {@link DocumentState} —
509
+ * `{ data, isLoading, isSynced, error }`, where `data` is `undefined` while
510
+ * the document is loading or the hook is disabled — and the component
511
+ * re-renders *only* when the returned slice changes (per `isEqual`). Status
512
+ * is not a freebie: read `s.isLoading`/`s.isSynced`/`s.error` here if you
513
+ * want to react to them (e.g. `s => ({ title: s.data?.title, saving:
514
+ * !s.isSynced })`). What you select is exactly what re-renders, and the
515
+ * returned handle exposes only the slice plus writers/`ref`.
516
+ */
517
+ selector: (state: DocumentState<TData>) => TSelected;
518
+ /**
519
+ * Decide whether two consecutive slices are equal; the hook re-renders only
520
+ * when this returns `false`. Defaults to a deep value comparison, so a
521
+ * selector that returns a fresh object/array of the same shape does not
522
+ * over-render. Pass {@link shallow} for a one-level compare, or a custom
523
+ * comparator.
524
+ */
525
+ isEqual?: (a: TSelected, b: TSelected) => boolean;
526
+ }
527
+ /**
528
+ * Opts a {@link useCollection} call into a selected slice. See
529
+ * {@link DocumentSelectorOptions}; the only difference is the selector receives
530
+ * the collection's keyed record.
531
+ */
532
+ interface CollectionSelectorOptions<TData extends FirestoreObject, TSelected> {
533
+ /**
534
+ * Project the collection's observable state down to the slice this component
535
+ * reacts to. The selector receives the full {@link CollectionState} —
536
+ * `{ data, isLoading, isSynced, isActive, error }`, where `data` is the keyed
537
+ * record (e.g. `s => s.data[id]` or `s => Object.keys(s.data)`) — and the
538
+ * component re-renders *only* when the returned slice changes. As with
539
+ * {@link DocumentSelectorOptions.selector}, status is reactive only if you
540
+ * select it, and the returned handle exposes just the slice plus
541
+ * writers/`ref`.
542
+ */
543
+ selector: (state: CollectionState<TData>) => TSelected;
544
+ /** See {@link DocumentSelectorOptions.isEqual}. */
545
+ isEqual?: (a: TSelected, b: TSelected) => boolean;
546
+ }
547
+ /**
548
+ * Shape used by the non-selector hook overload to *exclude* selector options,
549
+ * so passing a real `selector` falls through to the selector overload (which
550
+ * infers `TSelected`) instead of silently resolving to the full-data return.
551
+ */
552
+ type WithoutSelector = {
553
+ selector?: undefined;
554
+ isEqual?: undefined;
555
+ };
460
556
  /**
461
557
  * Context for providing the Firestate store
462
558
  */
@@ -497,10 +593,15 @@ interface UseDocumentOptions<TData extends FirestoreObject> {
497
593
  * Hook to subscribe to a Firestore document with real-time updates.
498
594
  *
499
595
  * The subscription is keyed on the resolved document path (`definition` +
500
- * computed id) and `readOnly`. When that key changes — typically because
501
- * `params` produces a different id — the hook tears down the old Firestore
502
- * listener and attaches a new one. Toggling `undoable` does not rebuild the
503
- * subscription.
596
+ * computed id). When that key changes — typically because `params` produces a
597
+ * different id — the hook tears down the old Firestore listener and attaches a
598
+ * new one. Toggling `undoable` does not rebuild the subscription.
599
+ *
600
+ * `readOnly` is a *per-handle capability*, not part of the key: a `readOnly`
601
+ * hook shares the same listener and optimistic state as a writable hook on the
602
+ * same document (a write through the writable handle is instantly visible to
603
+ * the read-only reader), and only this handle's own writers (`update`/`set`/
604
+ * `delete`) and `sync` are disabled.
504
605
  *
505
606
  * Use `enabled: false` to suppress the subscription entirely (e.g., when
506
607
  * route params aren't ready yet).
@@ -534,7 +635,24 @@ interface UseDocumentOptions<TData extends FirestoreObject> {
534
635
  * }
535
636
  * ```
536
637
  */
537
- declare const useDocument: <TData extends FirestoreObject>(options: UseDocumentOptions<TData>) => DocumentHandle<TData>;
638
+ declare function useDocument<TData extends FirestoreObject>(options: UseDocumentOptions<TData> & WithoutSelector): DocumentHandle<TData>;
639
+ /**
640
+ * Selector overload: pass `selector` to narrow the returned `data` to a slice
641
+ * and re-render only when that slice changes. Writers (`update`/`set`/`delete`)
642
+ * and `ref` keep operating on the full document. See
643
+ * {@link DocumentSelectorOptions}.
644
+ *
645
+ * @example
646
+ * ```tsx
647
+ * // Re-renders only when the title changes, not on any other field.
648
+ * const { data: title, update } = useDocument({
649
+ * definition: projectDoc,
650
+ * params: { projectId },
651
+ * selector: (s) => s.data?.title,
652
+ * })
653
+ * ```
654
+ */
655
+ declare function useDocument<TData extends FirestoreObject, TSelected>(options: UseDocumentOptions<TData> & DocumentSelectorOptions<TData, TSelected>): SelectedDocumentHandle<TData, TSelected>;
538
656
  /**
539
657
  * Options for useCollection hook
540
658
  */
@@ -559,14 +677,32 @@ interface UseCollectionOptions<TData extends FirestoreObject> {
559
677
  /**
560
678
  * Hook to subscribe to a Firestore collection with real-time updates.
561
679
  *
562
- * The subscription is keyed on the resolved collection path, `readOnly`, and
563
- * the `queryConstraints` reference. When any of these change, the listener
564
- * is torn down and re-attached with the new query. Toggling `undoable` does
565
- * not rebuild the subscription.
680
+ * The subscription is keyed on the resolved collection path and the *semantic
681
+ * identity* of `queryConstraints`. When either changes, the listener is torn
682
+ * down and re-attached with the new query. Toggling `undoable` does not rebuild
683
+ * the subscription. `readOnly` is a per-handle capability, not part of the key —
684
+ * a `readOnly` hook shares one listener and optimistic state with a writable
685
+ * hook on the same query (see {@link useDocument}).
566
686
  *
567
- * **Memoize `queryConstraints`.** An inline array (`queryConstraints={[where(...)]}`)
568
- * creates a new reference every render, which will thrash the listener.
569
- * Wrap in `useMemo` with the underlying filter values as deps.
687
+ * **You do not need to memoize `queryConstraints`.** `QueryConstraint` objects
688
+ * are opaque, so Firestate compares the *built query* with Firestore's own
689
+ * `queryEqual` instead of comparing array references. A fresh array that
690
+ * produces the same query (e.g. constraint inputs read from a document that
691
+ * Firestate deep-clones on optimistic updates) does not rebuild the listener;
692
+ * only a genuine change to the query does:
693
+ *
694
+ * ```tsx
695
+ * // stationIds may change reference on every edit to its parent document,
696
+ * // even when its contents are unchanged — the listener survives anyway.
697
+ * const stations = useCollection({
698
+ * definition: weatherStations,
699
+ * queryConstraints: [where(documentId(), 'in', stationIds)],
700
+ * })
701
+ * ```
702
+ *
703
+ * Memoizing is still a fine micro-optimization (it skips the per-render query
704
+ * build + compare via the reference fast-path), but it is no longer required
705
+ * for listener stability.
570
706
  *
571
707
  * Use `enabled: false` to suppress the subscription entirely (e.g., when
572
708
  * route params aren't ready yet).
@@ -604,7 +740,24 @@ interface UseCollectionOptions<TData extends FirestoreObject> {
604
740
  * }
605
741
  * ```
606
742
  */
607
- declare const useCollection: <TData extends FirestoreObject>(options: UseCollectionOptions<TData>) => CollectionHandle<TData>;
743
+ declare function useCollection<TData extends FirestoreObject>(options: UseCollectionOptions<TData> & WithoutSelector): CollectionHandle<TData>;
744
+ /**
745
+ * Selector overload: pass `selector` to narrow the returned `data` to a slice
746
+ * of the collection and re-render only when that slice changes. Writers
747
+ * (`update`/`add`/`remove`) and `ref` keep operating on the full collection.
748
+ * See {@link CollectionSelectorOptions}.
749
+ *
750
+ * @example
751
+ * ```tsx
752
+ * // Re-renders only when this one document's slice changes.
753
+ * const { data: space } = useCollection({
754
+ * definition: spacesCollection,
755
+ * params: { projectId },
756
+ * selector: (s) => s.data[spaceId],
757
+ * })
758
+ * ```
759
+ */
760
+ declare function useCollection<TData extends FirestoreObject, TSelected>(options: UseCollectionOptions<TData> & CollectionSelectorOptions<TData, TSelected>): SelectedCollectionHandle<TData, TSelected>;
608
761
  /**
609
762
  * Keyboard shortcut hook for undo/redo
610
763
  *
@@ -618,7 +771,7 @@ declare const useCollection: <TData extends FirestoreObject>(options: UseCollect
618
771
  */
619
772
  declare const useUndoKeyboardShortcuts: () => void;
620
773
  //#endregion
621
- //#region src/firestate.d.ts
774
+ //#region src/registry/firestate.d.ts
622
775
  /**
623
776
  * Knobs forwarded from a generated document hook to {@link useDocument}.
624
777
  * Same shape as `UseDocumentOptions` minus the fields the registry already
@@ -652,8 +805,13 @@ interface CommonEntryOptions {
652
805
  interface DocEntry<T extends FirestoreObject, P extends string = string> extends CommonEntryOptions {
653
806
  readonly __kind: "document";
654
807
  readonly __type?: T;
655
- /** Path template, e.g. `'taskLists/{listId}'`. */
656
- path: P;
808
+ /**
809
+ * Path template, e.g. `'taskLists/{listId}'`, or a function returning the
810
+ * **full document path** at runtime (the collection/id split happens
811
+ * per-call via {@link splitDocPath}). Use the function form for paths that
812
+ * branch on a param. See {@link PathArg}.
813
+ */
814
+ path: PathArg<P>;
657
815
  /**
658
816
  * Zod schema. **Required** — firestate's registry API is opinionated
659
817
  * about Zod. The schema is the source of `T` for the generated hooks
@@ -667,22 +825,126 @@ interface DocEntry<T extends FirestoreObject, P extends string = string> extends
667
825
  * param typing and no runtime validation.
668
826
  */
669
827
  schema: ZodType<T>;
828
+ /**
829
+ * Derive a **named slice-hook** off this document, sharing its schema and
830
+ * path — the schema is handed to firestate once, here, and never
831
+ * re-specified. The `selector` receives the full {@link DocumentState};
832
+ * return the slice the generated hook reacts to. For a *parameterized* slice,
833
+ * declare the extra params as the selector's second argument — the generated
834
+ * hook then requires the path params **and** those, merged into one bag.
835
+ *
836
+ * Pass the result to {@link createFirestate} under the key the hook is named
837
+ * for. Status is reactive only if the slice reads it, exactly as the inline
838
+ * `selector` option (see {@link DocumentHandle}); the comparator (`isEqual`)
839
+ * is baked in here, not passed per call.
840
+ *
841
+ * ```ts
842
+ * const project = doc({ path: 'projects/{projectId}', schema: ProjectSchema })
843
+ * const { useProject, useProjectTitle } = createFirestate({
844
+ * project, // → useProject (full)
845
+ * projectTitle: project.select((s) => s.data?.name), // → useProjectTitle
846
+ * })
847
+ * ```
848
+ *
849
+ * A derived entry is a leaf, not a base: there is intentionally no
850
+ * `.select(...).select(...)` chaining.
851
+ *
852
+ * `PExtra` (the selector's own params) defaults to `{}`: a one-argument
853
+ * selector leaves it unbound, a two-argument one infers it from the annotated
854
+ * second parameter. One signature keeps the selector's `state` arg reliably
855
+ * typed in both cases. `PExtra` is intentionally unconstrained — leaving it
856
+ * `extends Record<string, string>` made TS resolve a param-less selector's
857
+ * `PExtra` to that constraint (not the `{}` default), wrongly forcing a
858
+ * `params` arg on no-placeholder paths; unconstrained also lets a slice take
859
+ * non-string params (e.g. `{ index: number }`).
860
+ */
861
+ select<TSelected, PExtra = {}>(selector: (state: DocumentState<T>, params: PExtra) => TSelected, options?: SelectOptions<TSelected>): SelectedDocEntry<T, P, PExtra, TSelected>;
670
862
  }
671
863
  /** Collection entry in a Firestate registry. Produced by {@link col}. */
672
864
  interface ColEntry<T extends FirestoreObject, P extends string = string> extends CommonEntryOptions {
673
865
  readonly __kind: "collection";
674
866
  readonly __type?: T;
675
- /** Path template, e.g. `'taskLists/{listId}/tasks'`. */
676
- path: P;
867
+ /**
868
+ * Path template, e.g. `'taskLists/{listId}/tasks'`, or a function returning
869
+ * the **collection path** at runtime. Use the function form for paths that
870
+ * branch on a param. See {@link PathArg}.
871
+ */
872
+ path: PathArg<P>;
677
873
  /** Zod schema. Required. See {@link DocEntry.schema}. */
678
874
  schema: ZodType<T>;
679
875
  /** Only subscribe when `load()` is called. */
680
876
  lazy?: boolean;
681
877
  /** Additional Firestore query constraints. */
682
878
  queryConstraints?: QueryConstraint[];
879
+ /**
880
+ * Derive a **named slice-hook** off this collection, sharing its schema,
881
+ * path, and query — see {@link DocEntry.select}. The `selector` receives the
882
+ * full {@link CollectionState} (`s.data` is the keyed record), and a
883
+ * parameterized slice declares its extra params as the selector's second
884
+ * argument.
885
+ *
886
+ * ```ts
887
+ * const tasks = col({ path: 'projects/{projectId}/tasks', schema: TaskSchema })
888
+ * const { useTasks, useTaskIds, useTaskById } = createFirestate({
889
+ * tasks, // → useTasks (full)
890
+ * taskIds: tasks.select((s) => Object.keys(s.data)), // → useTaskIds
891
+ * taskById: tasks.select((s, p: { id: string }) => s.data[p.id]), // → useTaskById
892
+ * })
893
+ * // useTaskById requires the merged bag: useTaskById({ projectId, id })
894
+ * ```
895
+ *
896
+ * `PExtra` defaults to `{}` and is unconstrained — see {@link DocEntry.select}.
897
+ */
898
+ select<TSelected, PExtra = {}>(selector: (state: CollectionState<T>, params: PExtra) => TSelected, options?: SelectOptions<TSelected>): SelectedColEntry<T, P, PExtra, TSelected>;
899
+ }
900
+ /**
901
+ * Options bundled into a `.select(...)` entry at definition time. Kept separate
902
+ * from the runtime hook options (`enabled`/`readOnly`/`queryConstraints`)
903
+ * because these are baked into the named hook, not passed per call.
904
+ */
905
+ interface SelectOptions<TSelected> {
906
+ /**
907
+ * Comparator for this named hook's slice; the hook re-renders only when it
908
+ * returns `false`. Defaults to a deep value compare (so a fresh object/array
909
+ * of equal shape does not over-render). Pass {@link shallow} or a custom fn.
910
+ */
911
+ isEqual?: (a: TSelected, b: TSelected) => boolean;
912
+ }
913
+ /**
914
+ * A {@link DocEntry} narrowed by a `.select(...)` projection. Produced by
915
+ * {@link DocEntry.select}, consumed by {@link createFirestate}, which turns it
916
+ * into a hook whose `data` is the slice (`TSelected`) and whose params are the
917
+ * path params (`P`) merged with the selector's own params (`PExtra`).
918
+ *
919
+ * The schema/path/options live on `base` — a derived entry never re-declares
920
+ * them. `PExtra` is `{}` for an un-parameterized selector.
921
+ */
922
+ interface SelectedDocEntry<T extends FirestoreObject, P extends string, PExtra, TSelected> {
923
+ readonly __kind: "document-selected";
924
+ /** Base entry carrying schema/path/options — handed to firestate once. */
925
+ readonly base: DocEntry<T, P>;
926
+ /** Projection over the full state; receives the merged params bag at runtime. */
927
+ readonly selector: (state: DocumentState<T>, params: PExtra) => TSelected;
928
+ /** Comparator baked in at definition time (see {@link SelectOptions}). */
929
+ readonly isEqual?: (a: TSelected, b: TSelected) => boolean;
930
+ }
931
+ /**
932
+ * A {@link ColEntry} narrowed by a `.select(...)` projection. See
933
+ * {@link SelectedDocEntry}; the selector receives the collection's keyed state.
934
+ */
935
+ interface SelectedColEntry<T extends FirestoreObject, P extends string, PExtra, TSelected> {
936
+ readonly __kind: "collection-selected";
937
+ /** Base entry carrying schema/path/query/options — handed to firestate once. */
938
+ readonly base: ColEntry<T, P>;
939
+ /** Projection over the full state; receives the merged params bag at runtime. */
940
+ readonly selector: (state: CollectionState<T>, params: PExtra) => TSelected;
941
+ /** Comparator baked in at definition time (see {@link SelectOptions}). */
942
+ readonly isEqual?: (a: TSelected, b: TSelected) => boolean;
683
943
  }
684
944
  type FirestateEntry<T extends FirestoreObject = FirestoreObject, P extends string = string> = DocEntry<T, P> | ColEntry<T, P>;
685
- type FirestateRegistry = Record<string, FirestateEntry<any, any>>;
945
+ /** Any `.select(...)`-derived entry, regardless of its type parameters. */
946
+ type AnySelectedEntry = SelectedDocEntry<any, any, any, any> | SelectedColEntry<any, any, any, any>;
947
+ type FirestateRegistry = Record<string, FirestateEntry<any, any> | AnySelectedEntry>;
686
948
  /**
687
949
  * Extract `{name}` placeholders from a path template into a params shape.
688
950
  *
@@ -696,8 +958,20 @@ type FirestateRegistry = Record<string, FirestateEntry<any, any>>;
696
958
  type ParamsOf<P extends string> = string extends P ? Record<string, string> : Prettify<RawParamsOf<P>>;
697
959
  type RawParamsOf<P extends string> = P extends `${string}{${infer K}}${infer Rest}` ? { [Key in K]: string } & RawParamsOf<Rest> : {};
698
960
  type Prettify<T> = { [K in keyof T]: T[K] } & {};
699
- type DocOpts<T extends FirestoreObject> = Omit<DocEntry<T>, "__kind" | "__type" | "path">;
700
- type ColOpts<T extends FirestoreObject> = Omit<ColEntry<T>, "__kind" | "__type" | "path">;
961
+ /**
962
+ * The `path` accepted by {@link doc} / {@link col}. Either a static template
963
+ * (whose `{param}` placeholders are interpolated and whose param keys are
964
+ * inferred via {@link ParamsOf}), or a function that returns the path at
965
+ * runtime — for paths that branch on a param, e.g. live
966
+ * `projects/{projectId}/spaces` vs. revision
967
+ * `projects/{projectId}/revisions/{revisionId}/spaces`.
968
+ *
969
+ * With the function form, params can't be inferred from a template, so the
970
+ * generated hook's params fall back to `Record<string, string>`.
971
+ */
972
+ type PathArg<P extends string> = P | ((params: Record<string, string>) => string);
973
+ type DocOpts<T extends FirestoreObject> = Omit<DocEntry<T>, "__kind" | "__type" | "path" | "select">;
974
+ type ColOpts<T extends FirestoreObject> = Omit<ColEntry<T>, "__kind" | "__type" | "path" | "select">;
701
975
  /**
702
976
  * Declare a single-document entry for a Firestate registry.
703
977
  *
@@ -711,6 +985,10 @@ type ColOpts<T extends FirestoreObject> = Omit<ColEntry<T>, "__kind" | "__type"
711
985
  * directly — that escape hatch keeps the plain-TypeScript form, at the
712
986
  * cost of looser param typing on the hook and no runtime validation.
713
987
  *
988
+ * `path` may also be a function returning the full document path at runtime —
989
+ * for paths that branch on a param. Param keys can't be inferred from a
990
+ * function, so they fall back to `Record<string, string>`. See {@link PathArg}.
991
+ *
714
992
  * ```ts
715
993
  * import { z } from 'zod'
716
994
  *
@@ -721,18 +999,43 @@ type ColOpts<T extends FirestoreObject> = Omit<ColEntry<T>, "__kind" | "__type"
721
999
  */
722
1000
  declare function doc<S extends ZodType<FirestoreObject>, const P extends string = string>(opts: Omit<DocOpts<z.infer<S>>, "schema"> & {
723
1001
  schema: S;
724
- path: P;
1002
+ path: PathArg<P>;
725
1003
  }): DocEntry<z.infer<S>, P>;
726
1004
  /**
727
1005
  * Declare a collection entry for a Firestate registry. See {@link doc}
728
- * for the schema/typing contract.
1006
+ * for the schema/typing contract. `path` may also be a function returning
1007
+ * the collection path at runtime — see {@link PathArg}.
729
1008
  */
730
1009
  declare function col<S extends ZodType<FirestoreObject>, const P extends string = string>(opts: Omit<ColOpts<z.infer<S>>, "schema"> & {
731
1010
  schema: S;
732
- path: P;
1011
+ path: PathArg<P>;
733
1012
  }): ColEntry<z.infer<S>, P>;
734
1013
  type HookName<K extends string> = `use${Capitalize<K>}`;
735
- 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;
1014
+ type NoSelector = {
1015
+ selector?: undefined;
1016
+ isEqual?: undefined;
1017
+ };
1018
+ interface DocHookOptionalParams<T extends FirestoreObject> {
1019
+ (params?: Record<string, string>, options?: DocHookOptions<T> & NoSelector): DocumentHandle<T>;
1020
+ <TSelected>(params: Record<string, string> | undefined, options: DocHookOptions<T> & DocumentSelectorOptions<T, TSelected>): SelectedDocumentHandle<T, TSelected>;
1021
+ }
1022
+ interface DocHookRequiredParams<T extends FirestoreObject, P extends string> {
1023
+ (params: ParamsOf<P>, options?: DocHookOptions<T> & NoSelector): DocumentHandle<T>;
1024
+ <TSelected>(params: ParamsOf<P>, options: DocHookOptions<T> & DocumentSelectorOptions<T, TSelected>): SelectedDocumentHandle<T, TSelected>;
1025
+ }
1026
+ interface ColHookOptionalParams<T extends FirestoreObject> {
1027
+ (params?: Record<string, string>, options?: ColHookOptions<T> & NoSelector): CollectionHandle<T>;
1028
+ <TSelected>(params: Record<string, string> | undefined, options: ColHookOptions<T> & CollectionSelectorOptions<T, TSelected>): SelectedCollectionHandle<T, TSelected>;
1029
+ }
1030
+ interface ColHookRequiredParams<T extends FirestoreObject, P extends string> {
1031
+ (params: ParamsOf<P>, options?: ColHookOptions<T> & NoSelector): CollectionHandle<T>;
1032
+ <TSelected>(params: ParamsOf<P>, options: ColHookOptions<T> & CollectionSelectorOptions<T, TSelected>): SelectedCollectionHandle<T, TSelected>;
1033
+ }
1034
+ type IsAny<T> = 0 extends 1 & T ? true : false;
1035
+ type SelectedParams<P extends string, PExtra> = IsAny<PExtra> extends true ? ParamsOf<P> : Prettify<ParamsOf<P> & PExtra>;
1036
+ type SelectedDocHookFor<T extends FirestoreObject, P extends string, PExtra, TSelected> = keyof SelectedParams<P, PExtra> extends never ? (params?: Record<string, string>, options?: DocHookOptions<T>) => SelectedDocumentHandle<T, TSelected> : (params: SelectedParams<P, PExtra>, options?: DocHookOptions<T>) => SelectedDocumentHandle<T, TSelected>;
1037
+ type SelectedColHookFor<T extends FirestoreObject, P extends string, PExtra, TSelected> = keyof SelectedParams<P, PExtra> extends never ? (params?: Record<string, string>, options?: ColHookOptions<T>) => SelectedCollectionHandle<T, TSelected> : (params: SelectedParams<P, PExtra>, options?: ColHookOptions<T>) => SelectedCollectionHandle<T, TSelected>;
1038
+ type HookFor<E> = E extends SelectedDocEntry<infer T, infer P, infer PExtra, infer TSelected> ? SelectedDocHookFor<T, P, PExtra, TSelected> : E extends SelectedColEntry<infer T, infer P, infer PExtra, infer TSelected> ? SelectedColHookFor<T, P, PExtra, TSelected> : E extends DocEntry<infer T, infer P> ? keyof ParamsOf<P> extends never ? DocHookOptionalParams<T> : DocHookRequiredParams<T, P> : E extends ColEntry<infer T, infer P> ? keyof ParamsOf<P> extends never ? ColHookOptionalParams<T> : ColHookRequiredParams<T, P> : never;
736
1039
  type FirestateApi<R extends FirestateRegistry> = { [K in keyof R & string as HookName<K>]: HookFor<R[K]> };
737
1040
  /**
738
1041
  * Turn a Firestate registry into a map of typed React hooks. Each entry
@@ -747,7 +1050,7 @@ type FirestateApi<R extends FirestateRegistry> = { [K in keyof R & string as Hoo
747
1050
  */
748
1051
  declare function createFirestate<R extends FirestateRegistry>(registry: R): FirestateApi<R>;
749
1052
  //#endregion
750
- //#region src/diff.d.ts
1053
+ //#region src/utils/diff.d.ts
751
1054
  /**
752
1055
  * Check if two values are deeply equal
753
1056
  */
@@ -805,11 +1108,44 @@ declare const isDiffEmpty: (diff: Record<string, unknown>) => boolean;
805
1108
  * VectorValue) are NOT flattened — they're preserved at their path so
806
1109
  * Firestore receives them in their original form.
807
1110
  *
1111
+ * ⚠️ The dot-joined keys this produces are AMBIGUOUS to Firestore if any map
1112
+ * key itself contains a "." (e.g. an email key `a@b.com`): `updateDoc` parses
1113
+ * the "." as a path separator and writes to the wrong nested field. The
1114
+ * write path uses {@link flattenDiffToFieldPaths} + `FieldPath` instead, which
1115
+ * keeps every segment literal. Reach for this only when dotted-string keys are
1116
+ * what you actually want.
1117
+ *
808
1118
  * @param diff - The nested diff object
809
1119
  * @param prefix - Internal: current path prefix for recursion
810
1120
  * @returns Flattened object with dotted keys
811
1121
  */
812
1122
  declare const flattenDiff: (diff: Record<string, unknown>, prefix?: string) => Record<string, unknown>;
1123
+ /**
1124
+ * Flatten a nested diff into a list of `{ segments, value }` entries, where
1125
+ * `segments` is the path expressed as an array of *literal* key segments
1126
+ * (never joined into a dotted string).
1127
+ *
1128
+ * This is the segment-preserving sibling of {@link flattenDiff}. It exists
1129
+ * because dot-joined string keys are ambiguous to Firestore: `updateDoc(ref,
1130
+ * { "users.a@b.com.role": 4 })` parses the `.` inside the email key as a path
1131
+ * separator and writes to `users → "a@b" → "com" → role` instead of the
1132
+ * literal key `"a@b.com"`. Feeding these segments to `new FieldPath(...segments)`
1133
+ * (with the variadic `updateDoc(ref, fieldPath, value, …)` form) keeps every
1134
+ * segment literal, so a "." inside a map key is never re-interpreted.
1135
+ *
1136
+ * The opaque/plain-object rules mirror {@link flattenDiff} exactly: arrays,
1137
+ * FieldValue sentinels (deleteField, serverTimestamp, …), and Firestore value
1138
+ * types (Timestamp, DocumentReference, GeoPoint, Bytes, VectorValue) ride
1139
+ * through verbatim at their path; only plain objects are recursed into.
1140
+ *
1141
+ * @param diff - The nested diff object
1142
+ * @param prefix - Internal: accumulated path segments for recursion
1143
+ * @returns Flat list of `{ segments, value }` entries
1144
+ */
1145
+ declare const flattenDiffToFieldPaths: (diff: Record<string, unknown>, prefix?: string[]) => Array<{
1146
+ segments: string[];
1147
+ value: unknown;
1148
+ }>;
813
1149
  /**
814
1150
  * Merge two diffs together, with the second taking precedence
815
1151
  */
@@ -912,7 +1248,29 @@ declare const createDiffAtPath: (path: string, value: unknown) => Record<string,
912
1248
  */
913
1249
  declare const unflattenDiff: (flatDiff: Record<string, unknown>) => Record<string, unknown>;
914
1250
  //#endregion
915
- //#region src/document.d.ts
1251
+ //#region src/utils/shallow.d.ts
1252
+ /**
1253
+ * Shallow structural equality.
1254
+ *
1255
+ * Returns `true` when `a` and `b` are identical by `Object.is`, or are two
1256
+ * arrays / two plain objects whose entries are pairwise `Object.is`-equal one
1257
+ * level deep. Anything else (different shapes, nested objects that aren't
1258
+ * reference-equal) is `false`.
1259
+ *
1260
+ * Intended as the `isEqual` for a hook `selector` that builds a fresh array or
1261
+ * object every render — e.g. `data => Object.values(data).map(d => d.id)` or
1262
+ * `data => ({ name: data?.name, done: data?.done })`. The default selector
1263
+ * comparison is a *deep* value compare, which is correct but does more work
1264
+ * than needed for a flat projection; `shallow` re-renders on a genuine change
1265
+ * to any entry while collapsing the fresh-reference-same-entries case.
1266
+ *
1267
+ * Not recursive on purpose: if a selected entry is itself an object you mutate
1268
+ * in place rather than replace, prefer the default deep comparison or a
1269
+ * bespoke `isEqual`.
1270
+ */
1271
+ declare const shallow: <T>(a: T, b: T) => boolean;
1272
+ //#endregion
1273
+ //#region src/core/document.d.ts
916
1274
  /**
917
1275
  * Options for creating a document subscription
918
1276
  */
@@ -965,7 +1323,7 @@ declare const createDocumentSubscription: <TData extends FirestoreObject>(option
965
1323
  sync: () => Promise<void>;
966
1324
  };
967
1325
  //#endregion
968
- //#region src/collection.d.ts
1326
+ //#region src/core/collection.d.ts
969
1327
  /**
970
1328
  * Options for creating a collection subscription
971
1329
  */
@@ -1015,7 +1373,7 @@ declare const createCollectionSubscription: <TData extends FirestoreObject>(opti
1015
1373
  sync: () => Promise<void>;
1016
1374
  };
1017
1375
  //#endregion
1018
- //#region src/provider.d.ts
1376
+ //#region src/react/provider.d.ts
1019
1377
  /**
1020
1378
  * Props for FirestateProvider
1021
1379
  */
@@ -1131,5 +1489,5 @@ declare const FirestateStoreProvider: React.FC<FirestateStoreProviderProps>;
1131
1489
  */
1132
1490
  declare const useUnsavedChangesBlocker: () => boolean;
1133
1491
  //#endregion
1134
- 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 };
1492
+ export { type AnySelectedEntry, type ColEntry, type CollectionDefinition, type CollectionHandle, type CollectionOptions, type CollectionSelectorOptions, type CollectionState, type DeepPartial, type DocEntry, type DocumentDefinition, type DocumentHandle, type DocumentOptions, type DocumentSelectorOptions, 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 SelectOptions, type SelectedColEntry, type SelectedCollectionHandle, type SelectedDocEntry, type SelectedDocumentHandle, 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, flattenDiffToFieldPaths, isDeepEqual, isDiffEmpty, mergeDiffs, shallow, unflattenDiff, useCollection, useDocument, useIsSynced, useStore, useUndoKeyboardShortcuts, useUndoManager, useUnsavedChangesBlocker };
1135
1493
  //# sourceMappingURL=index.d.mts.map