@ttt-productions/query-core 0.14.3 → 0.14.5

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.
@@ -27,9 +27,17 @@ export declare function serializeInvalidation(inv: CacheInvalidation): string;
27
27
  /**
28
28
  * Dispatch a deduplicated list of cache invalidations.
29
29
  *
30
- * Synchronous by contract: the underlying `invalidateQueries` / `cancelQueries`
31
- * promises are fire-and-forget, so a mutation success path never awaits its own
32
- * cache refresh.
30
+ * Returns a promise that resolves once every `invalidateQueries` call it issued
31
+ * has settled — i.e. once the refetches those invalidations triggered have
32
+ * landed. The dispatch itself stays synchronous (every cancellation and
33
+ * invalidation is issued before this function returns), so a fire-and-forget
34
+ * caller keeps the prior behavior exactly, while a mutation `onSuccess` that
35
+ * RETURNS or awaits the promise stays pending until the refreshed data is in
36
+ * the cache. The promise never rejects: `invalidateQueries` refetches without
37
+ * `throwOnError`, so a failed refetch surfaces on its own query, never as a
38
+ * failure of the committed action that triggered it. The scoped
39
+ * `cancelQueries` calls stay fire-and-forget — cancellation takes effect
40
+ * synchronously and is not part of the refresh being awaited.
33
41
  *
34
42
  * Before each invalidation, any query it matches that is mid-PENDING-INITIAL-READ
35
43
  * is cancelled (see `cancelPendingInitialReads`) so a read that started before
@@ -45,5 +53,5 @@ export declare function serializeInvalidation(inv: CacheInvalidation): string;
45
53
  * refetch at all, so cancelling would abandon the only in-flight read the
46
54
  * consumer has and leave it with no data and no pending request.
47
55
  */
48
- export declare function applyInvalidations(queryClient: QueryClient, invalidations: ReadonlyArray<CacheInvalidation>): void;
56
+ export declare function applyInvalidations(queryClient: QueryClient, invalidations: ReadonlyArray<CacheInvalidation>): Promise<void>;
49
57
  //# sourceMappingURL=cache-invalidation.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"cache-invalidation.d.ts","sourceRoot":"","sources":["../../src/domain-events/cache-invalidation.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,WAAW,EAAgB,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAExF,MAAM,MAAM,WAAW,GAAG,QAAQ,GAAG,KAAK,GAAG,MAAM,CAAC;AAEpD,MAAM,MAAM,iBAAiB,GACzB;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAC;IAAC,WAAW,CAAC,EAAE,WAAW,CAAA;CAAE,GAChE;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAC;IAAC,WAAW,CAAC,EAAE,WAAW,CAAA;CAAE,GACjE;IACE,IAAI,EAAE,WAAW,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC;IACjC,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B,CAAC;AAEN,wBAAgB,KAAK,CAAC,QAAQ,EAAE,QAAQ,EAAE,IAAI,CAAC,EAAE;IAAE,WAAW,CAAC,EAAE,WAAW,CAAA;CAAE,GAAG,iBAAiB,CAEjG;AAED,wBAAgB,MAAM,CAAC,QAAQ,EAAE,QAAQ,EAAE,IAAI,CAAC,EAAE;IAAE,WAAW,CAAC,EAAE,WAAW,CAAA;CAAE,GAAG,iBAAiB,CAElG;AAED,wBAAgB,SAAS,CACvB,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,EAChC,IAAI,CAAC,EAAE;IAAE,WAAW,CAAC,EAAE,WAAW,CAAA;CAAE,GACnC,iBAAiB,CAEnB;AAED,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,iBAAiB,GAAG,MAAM,CAGpE;AAiCD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,kBAAkB,CAChC,WAAW,EAAE,WAAW,EACxB,aAAa,EAAE,aAAa,CAAC,iBAAiB,CAAC,GAC9C,IAAI,CAgBN"}
1
+ {"version":3,"file":"cache-invalidation.d.ts","sourceRoot":"","sources":["../../src/domain-events/cache-invalidation.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,WAAW,EAAgB,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAExF,MAAM,MAAM,WAAW,GAAG,QAAQ,GAAG,KAAK,GAAG,MAAM,CAAC;AAEpD,MAAM,MAAM,iBAAiB,GACzB;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAC;IAAC,WAAW,CAAC,EAAE,WAAW,CAAA;CAAE,GAChE;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAC;IAAC,WAAW,CAAC,EAAE,WAAW,CAAA;CAAE,GACjE;IACE,IAAI,EAAE,WAAW,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC;IACjC,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B,CAAC;AAEN,wBAAgB,KAAK,CAAC,QAAQ,EAAE,QAAQ,EAAE,IAAI,CAAC,EAAE;IAAE,WAAW,CAAC,EAAE,WAAW,CAAA;CAAE,GAAG,iBAAiB,CAEjG;AAED,wBAAgB,MAAM,CAAC,QAAQ,EAAE,QAAQ,EAAE,IAAI,CAAC,EAAE;IAAE,WAAW,CAAC,EAAE,WAAW,CAAA;CAAE,GAAG,iBAAiB,CAElG;AAED,wBAAgB,SAAS,CACvB,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,EAChC,IAAI,CAAC,EAAE;IAAE,WAAW,CAAC,EAAE,WAAW,CAAA;CAAE,GACnC,iBAAiB,CAEnB;AAED,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,iBAAiB,GAAG,MAAM,CAGpE;AAiCD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,kBAAkB,CAChC,WAAW,EAAE,WAAW,EACxB,aAAa,EAAE,aAAa,CAAC,iBAAiB,CAAC,GAC9C,OAAO,CAAC,IAAI,CAAC,CAkBf"}
@@ -48,9 +48,17 @@ function cancelPendingInitialReads(queryClient, filters) {
48
48
  /**
49
49
  * Dispatch a deduplicated list of cache invalidations.
50
50
  *
51
- * Synchronous by contract: the underlying `invalidateQueries` / `cancelQueries`
52
- * promises are fire-and-forget, so a mutation success path never awaits its own
53
- * cache refresh.
51
+ * Returns a promise that resolves once every `invalidateQueries` call it issued
52
+ * has settled — i.e. once the refetches those invalidations triggered have
53
+ * landed. The dispatch itself stays synchronous (every cancellation and
54
+ * invalidation is issued before this function returns), so a fire-and-forget
55
+ * caller keeps the prior behavior exactly, while a mutation `onSuccess` that
56
+ * RETURNS or awaits the promise stays pending until the refreshed data is in
57
+ * the cache. The promise never rejects: `invalidateQueries` refetches without
58
+ * `throwOnError`, so a failed refetch surfaces on its own query, never as a
59
+ * failure of the committed action that triggered it. The scoped
60
+ * `cancelQueries` calls stay fire-and-forget — cancellation takes effect
61
+ * synchronously and is not part of the refresh being awaited.
54
62
  *
55
63
  * Before each invalidation, any query it matches that is mid-PENDING-INITIAL-READ
56
64
  * is cancelled (see `cancelPendingInitialReads`) so a read that started before
@@ -68,6 +76,7 @@ function cancelPendingInitialReads(queryClient, filters) {
68
76
  */
69
77
  export function applyInvalidations(queryClient, invalidations) {
70
78
  const seen = new Set();
79
+ const refreshes = [];
71
80
  for (const inv of invalidations) {
72
81
  const sig = serializeInvalidation(inv);
73
82
  if (seen.has(sig))
@@ -78,7 +87,8 @@ export function applyInvalidations(queryClient, invalidations) {
78
87
  if (refetchType !== "none") {
79
88
  cancelPendingInitialReads(queryClient, filters);
80
89
  }
81
- void queryClient.invalidateQueries({ ...filters, refetchType });
90
+ refreshes.push(queryClient.invalidateQueries({ ...filters, refetchType }));
82
91
  }
92
+ return Promise.all(refreshes).then(() => undefined);
83
93
  }
84
94
  //# sourceMappingURL=cache-invalidation.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"cache-invalidation.js","sourceRoot":"","sources":["../../src/domain-events/cache-invalidation.ts"],"names":[],"mappings":"AAcA,MAAM,UAAU,KAAK,CAAC,QAAkB,EAAE,IAAoC;IAC5E,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC;AACrE,CAAC;AAED,MAAM,UAAU,MAAM,CAAC,QAAkB,EAAE,IAAoC;IAC7E,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,QAAQ,EAAE,WAAW,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC;AACtE,CAAC;AAED,MAAM,UAAU,SAAS,CACvB,WAAmB,EACnB,KAAgC,EAChC,IAAoC;IAEpC,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,WAAW,EAAE,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC;AACnF,CAAC;AAED,MAAM,UAAU,qBAAqB,CAAC,GAAsB;IAC1D,IAAI,GAAG,CAAC,IAAI,KAAK,WAAW;QAAE,OAAO,aAAa,GAAG,CAAC,WAAW,EAAE,CAAC;IACpE,OAAO,GAAG,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,CAAC;AACvD,CAAC;AAED,kFAAkF;AAClF,SAAS,mBAAmB,CAAC,GAAsB;IACjD,IAAI,GAAG,CAAC,IAAI,KAAK,OAAO;QAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,CAAC,QAAQ,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC;IACzE,IAAI,GAAG,CAAC,IAAI,KAAK,QAAQ;QAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,CAAC,QAAQ,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;IAC3E,OAAO,EAAE,SAAS,EAAE,GAAG,CAAC,KAAK,EAAE,CAAC;AAClC,CAAC;AAED;;;;;;;;;;;;;;;GAeG;AACH,SAAS,yBAAyB,CAAC,WAAwB,EAAE,OAAqB;IAChF,KAAK,MAAM,KAAK,IAAI,WAAW,CAAC,aAAa,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QACjE,IAAI,KAAK,CAAC,KAAK,CAAC,WAAW,KAAK,UAAU;YAAE,SAAS;QACrD,IAAI,KAAK,CAAC,KAAK,CAAC,IAAI,KAAK,SAAS;YAAE,SAAS;QAC7C,KAAK,WAAW,CAAC,aAAa,CAAC,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;IAC5E,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,UAAU,kBAAkB,CAChC,WAAwB,EACxB,aAA+C;IAE/C,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAC/B,KAAK,MAAM,GAAG,IAAI,aAAa,EAAE,CAAC;QAChC,MAAM,GAAG,GAAG,qBAAqB,CAAC,GAAG,CAAC,CAAC;QACvC,IAAI,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC;YAAE,SAAS;QAC5B,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAEd,MAAM,WAAW,GAAgB,GAAG,CAAC,WAAW,IAAI,QAAQ,CAAC;QAC7D,MAAM,OAAO,GAAG,mBAAmB,CAAC,GAAG,CAAC,CAAC;QAEzC,IAAI,WAAW,KAAK,MAAM,EAAE,CAAC;YAC3B,yBAAyB,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC;QAClD,CAAC;QAED,KAAK,WAAW,CAAC,iBAAiB,CAAC,EAAE,GAAG,OAAO,EAAE,WAAW,EAAE,CAAC,CAAC;IAClE,CAAC;AACH,CAAC","sourcesContent":["import type { Query, QueryClient, QueryFilters, QueryKey } from \"@tanstack/react-query\";\n\nexport type RefetchType = \"active\" | \"all\" | \"none\";\n\nexport type CacheInvalidation =\n | { kind: \"exact\"; queryKey: QueryKey; refetchType?: RefetchType }\n | { kind: \"prefix\"; queryKey: QueryKey; refetchType?: RefetchType }\n | {\n kind: \"predicate\";\n description: string;\n match: (query: Query) => boolean;\n refetchType?: RefetchType;\n };\n\nexport function exact(queryKey: QueryKey, opts?: { refetchType?: RefetchType }): CacheInvalidation {\n return { kind: \"exact\", queryKey, refetchType: opts?.refetchType };\n}\n\nexport function prefix(queryKey: QueryKey, opts?: { refetchType?: RefetchType }): CacheInvalidation {\n return { kind: \"prefix\", queryKey, refetchType: opts?.refetchType };\n}\n\nexport function predicate(\n description: string,\n match: (query: Query) => boolean,\n opts?: { refetchType?: RefetchType },\n): CacheInvalidation {\n return { kind: \"predicate\", description, match, refetchType: opts?.refetchType };\n}\n\nexport function serializeInvalidation(inv: CacheInvalidation): string {\n if (inv.kind === \"predicate\") return `predicate:${inv.description}`;\n return `${inv.kind}:${JSON.stringify(inv.queryKey)}`;\n}\n\n/** The cache filter that selects exactly the queries one invalidation targets. */\nfunction invalidationFilters(inv: CacheInvalidation): QueryFilters {\n if (inv.kind === \"exact\") return { queryKey: inv.queryKey, exact: true };\n if (inv.kind === \"prefix\") return { queryKey: inv.queryKey, exact: false };\n return { predicate: inv.match };\n}\n\n/**\n * Cancel every query matched by `filters` that is in a PENDING INITIAL READ —\n * currently fetching with no data yet.\n *\n * TanStack's `Query.fetch` only cancels a running request on refetch when the\n * query already holds data; an initial fetch in flight is REUSED instead, so\n * the invalidation's refetch resolves with the value the server returned BEFORE\n * the mutation committed and then marks the query fresh. Cancelling first makes\n * the invalidation's refetch a genuinely new request: the retryer is rejected\n * synchronously (its `onCancel` reverts state to idle in the same tick), so the\n * `invalidateQueries` call that follows starts a fresh fetch, and the abandoned\n * promise's late, stale resolution is discarded.\n *\n * Cancellation is always scoped to one concrete query key — never a bare\n * `cancelQueries()`.\n */\nfunction cancelPendingInitialReads(queryClient: QueryClient, filters: QueryFilters): void {\n for (const query of queryClient.getQueryCache().findAll(filters)) {\n if (query.state.fetchStatus !== \"fetching\") continue;\n if (query.state.data !== undefined) continue;\n void queryClient.cancelQueries({ queryKey: query.queryKey, exact: true });\n }\n}\n\n/**\n * Dispatch a deduplicated list of cache invalidations.\n *\n * Synchronous by contract: the underlying `invalidateQueries` / `cancelQueries`\n * promises are fire-and-forget, so a mutation success path never awaits its own\n * cache refresh.\n *\n * Before each invalidation, any query it matches that is mid-PENDING-INITIAL-READ\n * is cancelled (see `cancelPendingInitialReads`) so a read that started before\n * the write committed can never land as the query's first — and freshly-marked —\n * value.\n *\n * `refetchType` governs whether that cancellation happens:\n * - `'active'` (the default) and `'all'` cancel-then-invalidate every matched\n * pending initial read — including one TanStack's refetch pass will not\n * restart, since a stale first value would otherwise land and mark the query\n * fresh, while a cancelled one simply refetches for its next observer.\n * - `'none'` invalidates WITHOUT cancelling: the caller has asked for no\n * refetch at all, so cancelling would abandon the only in-flight read the\n * consumer has and leave it with no data and no pending request.\n */\nexport function applyInvalidations(\n queryClient: QueryClient,\n invalidations: ReadonlyArray<CacheInvalidation>,\n): void {\n const seen = new Set<string>();\n for (const inv of invalidations) {\n const sig = serializeInvalidation(inv);\n if (seen.has(sig)) continue;\n seen.add(sig);\n\n const refetchType: RefetchType = inv.refetchType ?? \"active\";\n const filters = invalidationFilters(inv);\n\n if (refetchType !== \"none\") {\n cancelPendingInitialReads(queryClient, filters);\n }\n\n void queryClient.invalidateQueries({ ...filters, refetchType });\n }\n}\n"]}
1
+ {"version":3,"file":"cache-invalidation.js","sourceRoot":"","sources":["../../src/domain-events/cache-invalidation.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Query, QueryClient, QueryFilters, QueryKey } from \"@tanstack/react-query\";\n\nexport type RefetchType = \"active\" | \"all\" | \"none\";\n\nexport type CacheInvalidation =\n | { kind: \"exact\"; queryKey: QueryKey; refetchType?: RefetchType }\n | { kind: \"prefix\"; queryKey: QueryKey; refetchType?: RefetchType }\n | {\n kind: \"predicate\";\n description: string;\n match: (query: Query) => boolean;\n refetchType?: RefetchType;\n };\n\nexport function exact(queryKey: QueryKey, opts?: { refetchType?: RefetchType }): CacheInvalidation {\n return { kind: \"exact\", queryKey, refetchType: opts?.refetchType };\n}\n\nexport function prefix(queryKey: QueryKey, opts?: { refetchType?: RefetchType }): CacheInvalidation {\n return { kind: \"prefix\", queryKey, refetchType: opts?.refetchType };\n}\n\nexport function predicate(\n description: string,\n match: (query: Query) => boolean,\n opts?: { refetchType?: RefetchType },\n): CacheInvalidation {\n return { kind: \"predicate\", description, match, refetchType: opts?.refetchType };\n}\n\nexport function serializeInvalidation(inv: CacheInvalidation): string {\n if (inv.kind === \"predicate\") return `predicate:${inv.description}`;\n return `${inv.kind}:${JSON.stringify(inv.queryKey)}`;\n}\n\n/** The cache filter that selects exactly the queries one invalidation targets. */\nfunction invalidationFilters(inv: CacheInvalidation): QueryFilters {\n if (inv.kind === \"exact\") return { queryKey: inv.queryKey, exact: true };\n if (inv.kind === \"prefix\") return { queryKey: inv.queryKey, exact: false };\n return { predicate: inv.match };\n}\n\n/**\n * Cancel every query matched by `filters` that is in a PENDING INITIAL READ —\n * currently fetching with no data yet.\n *\n * TanStack's `Query.fetch` only cancels a running request on refetch when the\n * query already holds data; an initial fetch in flight is REUSED instead, so\n * the invalidation's refetch resolves with the value the server returned BEFORE\n * the mutation committed and then marks the query fresh. Cancelling first makes\n * the invalidation's refetch a genuinely new request: the retryer is rejected\n * synchronously (its `onCancel` reverts state to idle in the same tick), so the\n * `invalidateQueries` call that follows starts a fresh fetch, and the abandoned\n * promise's late, stale resolution is discarded.\n *\n * Cancellation is always scoped to one concrete query key — never a bare\n * `cancelQueries()`.\n */\nfunction cancelPendingInitialReads(queryClient: QueryClient, filters: QueryFilters): void {\n for (const query of queryClient.getQueryCache().findAll(filters)) {\n if (query.state.fetchStatus !== \"fetching\") continue;\n if (query.state.data !== undefined) continue;\n void queryClient.cancelQueries({ queryKey: query.queryKey, exact: true });\n }\n}\n\n/**\n * Dispatch a deduplicated list of cache invalidations.\n *\n * Returns a promise that resolves once every `invalidateQueries` call it issued\n * has settled — i.e. once the refetches those invalidations triggered have\n * landed. The dispatch itself stays synchronous (every cancellation and\n * invalidation is issued before this function returns), so a fire-and-forget\n * caller keeps the prior behavior exactly, while a mutation `onSuccess` that\n * RETURNS or awaits the promise stays pending until the refreshed data is in\n * the cache. The promise never rejects: `invalidateQueries` refetches without\n * `throwOnError`, so a failed refetch surfaces on its own query, never as a\n * failure of the committed action that triggered it. The scoped\n * `cancelQueries` calls stay fire-and-forget — cancellation takes effect\n * synchronously and is not part of the refresh being awaited.\n *\n * Before each invalidation, any query it matches that is mid-PENDING-INITIAL-READ\n * is cancelled (see `cancelPendingInitialReads`) so a read that started before\n * the write committed can never land as the query's first — and freshly-marked —\n * value.\n *\n * `refetchType` governs whether that cancellation happens:\n * - `'active'` (the default) and `'all'` cancel-then-invalidate every matched\n * pending initial read — including one TanStack's refetch pass will not\n * restart, since a stale first value would otherwise land and mark the query\n * fresh, while a cancelled one simply refetches for its next observer.\n * - `'none'` invalidates WITHOUT cancelling: the caller has asked for no\n * refetch at all, so cancelling would abandon the only in-flight read the\n * consumer has and leave it with no data and no pending request.\n */\nexport function applyInvalidations(\n queryClient: QueryClient,\n invalidations: ReadonlyArray<CacheInvalidation>,\n): Promise<void> {\n const seen = new Set<string>();\n const refreshes: Promise<void>[] = [];\n for (const inv of invalidations) {\n const sig = serializeInvalidation(inv);\n if (seen.has(sig)) continue;\n seen.add(sig);\n\n const refetchType: RefetchType = inv.refetchType ?? \"active\";\n const filters = invalidationFilters(inv);\n\n if (refetchType !== \"none\") {\n cancelPendingInitialReads(queryClient, filters);\n }\n\n refreshes.push(queryClient.invalidateQueries({ ...filters, refetchType }));\n }\n return Promise.all(refreshes).then(() => undefined);\n}\n"]}
@@ -16,8 +16,15 @@ export interface DomainEventInvalidator<TEvent extends {
16
16
  type: string;
17
17
  ids: unknown;
18
18
  }> {
19
- notify(queryClient: QueryClient, event: TEvent): void;
20
- notifyAll(queryClient: QueryClient, events: ReadonlyArray<TEvent>): void;
19
+ /**
20
+ * Dispatch the event's invalidations. The returned promise resolves once
21
+ * every refetch they triggered has settled, and never rejects — return or
22
+ * await it from a mutation's `onSuccess` to keep the mutation pending until
23
+ * the refreshed data lands; ignore it for fire-and-forget dispatch.
24
+ */
25
+ notify(queryClient: QueryClient, event: TEvent): Promise<void>;
26
+ /** `notify` for several events, deduplicating their invalidations as one batch. */
27
+ notifyAll(queryClient: QueryClient, events: ReadonlyArray<TEvent>): Promise<void>;
21
28
  /** Test/debug helpers. */
22
29
  readonly helpers: {
23
30
  exact: typeof exact;
@@ -1 +1 @@
1
- {"version":3,"file":"create-domain-event-invalidator.d.ts","sourceRoot":"","sources":["../../src/domain-events/create-domain-event-invalidator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACzD,OAAO,EAEL,KAAK,EACL,MAAM,EACN,SAAS,EACT,KAAK,iBAAiB,EACvB,MAAM,yBAAyB,CAAC;AAEjC;;;GAGG;AACH,MAAM,MAAM,+BAA+B,CAAC,MAAM,SAAS;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,OAAO,CAAA;CAAE,IAAI;KAC1F,CAAC,IAAI,MAAM,CAAC,MAAM,CAAC,GAAG,CACrB,GAAG,EAAE,OAAO,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,CAAC,CAAA;KAAE,CAAC,CAAC,KAAK,CAAC,KACrC,aAAa,CAAC,iBAAiB,CAAC;CACtC,CAAC;AAEF,MAAM,WAAW,sBAAsB,CAAC,MAAM,SAAS;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,OAAO,CAAA;CAAE;IACnF,MAAM,CAAC,WAAW,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACtD,SAAS,CAAC,WAAW,EAAE,WAAW,EAAE,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC;IACzE,0BAA0B;IAC1B,QAAQ,CAAC,OAAO,EAAE;QAChB,KAAK,EAAE,OAAO,KAAK,CAAC;QACpB,MAAM,EAAE,OAAO,MAAM,CAAC;QACtB,SAAS,EAAE,OAAO,SAAS,CAAC;KAC7B,CAAC;IACF,uDAAuD;IACvD,QAAQ,CAAC,QAAQ,EAAE,+BAA+B,CAAC,MAAM,CAAC,CAAC;CAC5D;AAED;;;;GAIG;AACH,wBAAgB,4BAA4B,CAAC,MAAM,SAAS;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,OAAO,CAAA;CAAE,EACxF,QAAQ,EAAE,+BAA+B,CAAC,MAAM,CAAC,GAChD,sBAAsB,CAAC,MAAM,CAAC,CA4BhC"}
1
+ {"version":3,"file":"create-domain-event-invalidator.d.ts","sourceRoot":"","sources":["../../src/domain-events/create-domain-event-invalidator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACzD,OAAO,EAEL,KAAK,EACL,MAAM,EACN,SAAS,EACT,KAAK,iBAAiB,EACvB,MAAM,yBAAyB,CAAC;AAEjC;;;GAGG;AACH,MAAM,MAAM,+BAA+B,CAAC,MAAM,SAAS;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,OAAO,CAAA;CAAE,IAAI;KAC1F,CAAC,IAAI,MAAM,CAAC,MAAM,CAAC,GAAG,CACrB,GAAG,EAAE,OAAO,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,CAAC,CAAA;KAAE,CAAC,CAAC,KAAK,CAAC,KACrC,aAAa,CAAC,iBAAiB,CAAC;CACtC,CAAC;AAEF,MAAM,WAAW,sBAAsB,CAAC,MAAM,SAAS;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,OAAO,CAAA;CAAE;IACnF;;;;;OAKG;IACH,MAAM,CAAC,WAAW,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/D,mFAAmF;IACnF,SAAS,CAAC,WAAW,EAAE,WAAW,EAAE,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClF,0BAA0B;IAC1B,QAAQ,CAAC,OAAO,EAAE;QAChB,KAAK,EAAE,OAAO,KAAK,CAAC;QACpB,MAAM,EAAE,OAAO,MAAM,CAAC;QACtB,SAAS,EAAE,OAAO,SAAS,CAAC;KAC7B,CAAC;IACF,uDAAuD;IACvD,QAAQ,CAAC,QAAQ,EAAE,+BAA+B,CAAC,MAAM,CAAC,CAAC;CAC5D;AAED;;;;GAIG;AACH,wBAAgB,4BAA4B,CAAC,MAAM,SAAS;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,OAAO,CAAA;CAAE,EACxF,QAAQ,EAAE,+BAA+B,CAAC,MAAM,CAAC,GAChD,sBAAsB,CAAC,MAAM,CAAC,CA4BhC"}
@@ -11,7 +11,7 @@ export function createDomainEventInvalidator(registry) {
11
11
  function notify(queryClient, event) {
12
12
  const fn = lookup(event.type);
13
13
  const invalidations = fn(event.ids);
14
- applyInvalidations(queryClient, invalidations);
14
+ return applyInvalidations(queryClient, invalidations);
15
15
  }
16
16
  function notifyAll(queryClient, events) {
17
17
  const all = [];
@@ -19,7 +19,7 @@ export function createDomainEventInvalidator(registry) {
19
19
  const fn = lookup(event.type);
20
20
  all.push(...fn(event.ids));
21
21
  }
22
- applyInvalidations(queryClient, all);
22
+ return applyInvalidations(queryClient, all);
23
23
  }
24
24
  return {
25
25
  notify,
@@ -1 +1 @@
1
- {"version":3,"file":"create-domain-event-invalidator.js","sourceRoot":"","sources":["../../src/domain-events/create-domain-event-invalidator.ts"],"names":[],"mappings":"AACA,OAAO,EACL,kBAAkB,EAClB,KAAK,EACL,MAAM,EACN,SAAS,GAEV,MAAM,yBAAyB,CAAC;AAyBjC;;;;GAIG;AACH,MAAM,UAAU,4BAA4B,CAC1C,QAAiD;IAEjD,SAAS,MAAM,CAA+B,IAAW;QACvD,OAAO,QAAQ,CAAC,IAAI,CAEiB,CAAC;IACxC,CAAC;IAED,SAAS,MAAM,CAAC,WAAwB,EAAE,KAAa;QACrD,MAAM,EAAE,GAAG,MAAM,CAAC,KAAK,CAAC,IAAsB,CAAC,CAAC;QAChD,MAAM,aAAa,GAAG,EAAE,CAAC,KAAK,CAAC,GAAuD,CAAC,CAAC;QACxF,kBAAkB,CAAC,WAAW,EAAE,aAAa,CAAC,CAAC;IACjD,CAAC;IAED,SAAS,SAAS,CAAC,WAAwB,EAAE,MAA6B;QACxE,MAAM,GAAG,GAAwB,EAAE,CAAC;QACpC,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;YAC3B,MAAM,EAAE,GAAG,MAAM,CAAC,KAAK,CAAC,IAAsB,CAAC,CAAC;YAChD,GAAG,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,GAAuD,CAAC,CAAC,CAAC;QACjF,CAAC;QACD,kBAAkB,CAAC,WAAW,EAAE,GAAG,CAAC,CAAC;IACvC,CAAC;IAED,OAAO;QACL,MAAM;QACN,SAAS;QACT,OAAO,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE;QACrC,QAAQ;KACT,CAAC;AACJ,CAAC","sourcesContent":["import type { QueryClient } from \"@tanstack/react-query\";\nimport {\n applyInvalidations,\n exact,\n prefix,\n predicate,\n type CacheInvalidation,\n} from \"./cache-invalidation.js\";\n\n/**\n * Registry shape: each event type maps to a function from the event's `ids`\n * payload to the list of cache invalidations that event should trigger.\n */\nexport type DomainEventInvalidationRegistry<TEvent extends { type: string; ids: unknown }> = {\n [K in TEvent[\"type\"]]: (\n ids: Extract<TEvent, { type: K }>[\"ids\"],\n ) => ReadonlyArray<CacheInvalidation>;\n};\n\nexport interface DomainEventInvalidator<TEvent extends { type: string; ids: unknown }> {\n notify(queryClient: QueryClient, event: TEvent): void;\n notifyAll(queryClient: QueryClient, events: ReadonlyArray<TEvent>): void;\n /** Test/debug helpers. */\n readonly helpers: {\n exact: typeof exact;\n prefix: typeof prefix;\n predicate: typeof predicate;\n };\n /** Direct access to the registry, mainly for tests. */\n readonly registry: DomainEventInvalidationRegistry<TEvent>;\n}\n\n/**\n * Build a domain-event → cache-invalidation notifier from a typed registry.\n * The registry's keys are statically tied to `TEvent[\"type\"]`, so missing or\n * extra entries are compile errors.\n */\nexport function createDomainEventInvalidator<TEvent extends { type: string; ids: unknown }>(\n registry: DomainEventInvalidationRegistry<TEvent>,\n): DomainEventInvalidator<TEvent> {\n function lookup<TType extends TEvent[\"type\"]>(type: TType) {\n return registry[type] as (\n ids: Extract<TEvent, { type: TType }>[\"ids\"],\n ) => ReadonlyArray<CacheInvalidation>;\n }\n\n function notify(queryClient: QueryClient, event: TEvent): void {\n const fn = lookup(event.type as TEvent[\"type\"]);\n const invalidations = fn(event.ids as Extract<TEvent, { type: TEvent[\"type\"] }>[\"ids\"]);\n applyInvalidations(queryClient, invalidations);\n }\n\n function notifyAll(queryClient: QueryClient, events: ReadonlyArray<TEvent>): void {\n const all: CacheInvalidation[] = [];\n for (const event of events) {\n const fn = lookup(event.type as TEvent[\"type\"]);\n all.push(...fn(event.ids as Extract<TEvent, { type: TEvent[\"type\"] }>[\"ids\"]));\n }\n applyInvalidations(queryClient, all);\n }\n\n return {\n notify,\n notifyAll,\n helpers: { exact, prefix, predicate },\n registry,\n };\n}\n"]}
1
+ {"version":3,"file":"create-domain-event-invalidator.js","sourceRoot":"","sources":["../../src/domain-events/create-domain-event-invalidator.ts"],"names":[],"mappings":"AACA,OAAO,EACL,kBAAkB,EAClB,KAAK,EACL,MAAM,EACN,SAAS,GAEV,MAAM,yBAAyB,CAAC;AAgCjC;;;;GAIG;AACH,MAAM,UAAU,4BAA4B,CAC1C,QAAiD;IAEjD,SAAS,MAAM,CAA+B,IAAW;QACvD,OAAO,QAAQ,CAAC,IAAI,CAEiB,CAAC;IACxC,CAAC;IAED,SAAS,MAAM,CAAC,WAAwB,EAAE,KAAa;QACrD,MAAM,EAAE,GAAG,MAAM,CAAC,KAAK,CAAC,IAAsB,CAAC,CAAC;QAChD,MAAM,aAAa,GAAG,EAAE,CAAC,KAAK,CAAC,GAAuD,CAAC,CAAC;QACxF,OAAO,kBAAkB,CAAC,WAAW,EAAE,aAAa,CAAC,CAAC;IACxD,CAAC;IAED,SAAS,SAAS,CAAC,WAAwB,EAAE,MAA6B;QACxE,MAAM,GAAG,GAAwB,EAAE,CAAC;QACpC,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;YAC3B,MAAM,EAAE,GAAG,MAAM,CAAC,KAAK,CAAC,IAAsB,CAAC,CAAC;YAChD,GAAG,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,GAAuD,CAAC,CAAC,CAAC;QACjF,CAAC;QACD,OAAO,kBAAkB,CAAC,WAAW,EAAE,GAAG,CAAC,CAAC;IAC9C,CAAC;IAED,OAAO;QACL,MAAM;QACN,SAAS;QACT,OAAO,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE;QACrC,QAAQ;KACT,CAAC;AACJ,CAAC","sourcesContent":["import type { QueryClient } from \"@tanstack/react-query\";\nimport {\n applyInvalidations,\n exact,\n prefix,\n predicate,\n type CacheInvalidation,\n} from \"./cache-invalidation.js\";\n\n/**\n * Registry shape: each event type maps to a function from the event's `ids`\n * payload to the list of cache invalidations that event should trigger.\n */\nexport type DomainEventInvalidationRegistry<TEvent extends { type: string; ids: unknown }> = {\n [K in TEvent[\"type\"]]: (\n ids: Extract<TEvent, { type: K }>[\"ids\"],\n ) => ReadonlyArray<CacheInvalidation>;\n};\n\nexport interface DomainEventInvalidator<TEvent extends { type: string; ids: unknown }> {\n /**\n * Dispatch the event's invalidations. The returned promise resolves once\n * every refetch they triggered has settled, and never rejects — return or\n * await it from a mutation's `onSuccess` to keep the mutation pending until\n * the refreshed data lands; ignore it for fire-and-forget dispatch.\n */\n notify(queryClient: QueryClient, event: TEvent): Promise<void>;\n /** `notify` for several events, deduplicating their invalidations as one batch. */\n notifyAll(queryClient: QueryClient, events: ReadonlyArray<TEvent>): Promise<void>;\n /** Test/debug helpers. */\n readonly helpers: {\n exact: typeof exact;\n prefix: typeof prefix;\n predicate: typeof predicate;\n };\n /** Direct access to the registry, mainly for tests. */\n readonly registry: DomainEventInvalidationRegistry<TEvent>;\n}\n\n/**\n * Build a domain-event → cache-invalidation notifier from a typed registry.\n * The registry's keys are statically tied to `TEvent[\"type\"]`, so missing or\n * extra entries are compile errors.\n */\nexport function createDomainEventInvalidator<TEvent extends { type: string; ids: unknown }>(\n registry: DomainEventInvalidationRegistry<TEvent>,\n): DomainEventInvalidator<TEvent> {\n function lookup<TType extends TEvent[\"type\"]>(type: TType) {\n return registry[type] as (\n ids: Extract<TEvent, { type: TType }>[\"ids\"],\n ) => ReadonlyArray<CacheInvalidation>;\n }\n\n function notify(queryClient: QueryClient, event: TEvent): Promise<void> {\n const fn = lookup(event.type as TEvent[\"type\"]);\n const invalidations = fn(event.ids as Extract<TEvent, { type: TEvent[\"type\"] }>[\"ids\"]);\n return applyInvalidations(queryClient, invalidations);\n }\n\n function notifyAll(queryClient: QueryClient, events: ReadonlyArray<TEvent>): Promise<void> {\n const all: CacheInvalidation[] = [];\n for (const event of events) {\n const fn = lookup(event.type as TEvent[\"type\"]);\n all.push(...fn(event.ids as Extract<TEvent, { type: TEvent[\"type\"] }>[\"ids\"]));\n }\n return applyInvalidations(queryClient, all);\n }\n\n return {\n notify,\n notifyAll,\n helpers: { exact, prefix, predicate },\n registry,\n };\n}\n"]}
@@ -23,6 +23,27 @@ export type FirestoreSourceState = 'connecting' | 'live' | 'offline' | 'error';
23
23
  export type WithSourceState<R> = R & {
24
24
  sourceState: FirestoreSourceState;
25
25
  };
26
+ /**
27
+ * One requested id's answer from `useBatchFirestoreDocs`:
28
+ * - `loading` — no answer yet (nothing cached for the id, or the hook is disabled).
29
+ * - `present` — the document exists and is readable; `data` is it.
30
+ * - `absent` — the read succeeded and the document does not exist.
31
+ * - `failed` — the read (or, in subscribe mode, the listener) errored; `error` is the cause.
32
+ * A failure is never reported as `absent`, and a document cached before the failure is
33
+ * not reported as `present`. What a failure means for a given collection (a rules denial
34
+ * that stands for "not there for this reader", say) is the consumer's to decide from `error`.
35
+ */
36
+ export type BatchFirestoreDocOutcome<T> = {
37
+ status: 'loading';
38
+ } | {
39
+ status: 'present';
40
+ data: T;
41
+ } | {
42
+ status: 'absent';
43
+ } | {
44
+ status: 'failed';
45
+ error: Error;
46
+ };
26
47
  /**
27
48
  * Extract document data with ID from a QueryDocumentSnapshot.
28
49
  */
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/firestore/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/firestore/types.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/firestore/types.ts"],"names":[],"mappings":"AA8BA;;GAEG;AACH,MAAM,UAAU,SAAS,CACvB,IAA8B;IAE9B,OAAO,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE,GAAG,IAAI,CAAC,IAAI,EAAE,EAAE,CAAC;AACzC,CAAC","sourcesContent":["import type {\n DocumentData,\n DocumentSnapshot,\n QueryDocumentSnapshot,\n QueryConstraint,\n} from 'firebase/firestore';\n\n/**\n * Document with ID included in the data object.\n */\nexport type WithId<T> = T & { id: string };\n\n/**\n * Metadata-derived liveness of a realtime subscription. Firestore serves cached\n * snapshots offline WITHOUT firing the error callback, so error-only detection\n * renders a stale cached empty result as \"live\". This four-state is derived from\n * `snapshot.metadata.fromCache` (server-confirmed vs cache) plus the listener\n * error callback:\n * - `connecting` — no server-confirmed snapshot yet (initial, or only cache-first data).\n * - `live` — at least one server-confirmed snapshot (`fromCache === false`).\n * - `offline` — only cached data after having been live (connectivity loss).\n * - `error` — the listener error callback fired (e.g. permission loss).\n *\n * Meaningful only for `subscribe: true`; a non-subscribed hook stays `connecting`.\n */\nexport type FirestoreSourceState = 'connecting' | 'live' | 'offline' | 'error';\n\n/** A query-hook result augmented with the metadata-derived subscription source state. */\nexport type WithSourceState<R> = R & { sourceState: FirestoreSourceState };\n\n/**\n * Extract document data with ID from a QueryDocumentSnapshot.\n */\nexport function docWithId<T extends DocumentData>(\n snap: QueryDocumentSnapshot<T>\n): WithId<T> {\n return { id: snap.id, ...snap.data() };\n}\n\n/**\n * Base options shared across all Firestore hooks.\n */\nexport interface FirestoreBaseOptions {\n /** React Query cache key */\n queryKey: readonly unknown[];\n /** Enable/disable the query */\n enabled?: boolean;\n /** Stale time override (ms) */\n staleTime?: number;\n /** Garbage collection time override (ms) */\n gcTime?: number;\n}\n\n/**\n * Options for single document queries.\n */\nexport interface FirestoreDocOptions<T> extends FirestoreBaseOptions {\n /** Full document path (e.g., 'users/abc123') */\n docPath: string;\n /** Enable realtime updates via onSnapshot (default: false) */\n subscribe?: boolean;\n /** Transform function applied to document data. Receives data WITH id included. */\n select?: (data: DocumentData & { id: string }) => T;\n}\n\n/**\n * Options for collection queries.\n */\nexport interface FirestoreCollectionOptions<T> extends FirestoreBaseOptions {\n /** Collection path (e.g., 'users' or 'entities/abc/members') */\n collectionPath: string;\n /** Firestore query constraints (where, orderBy, etc.) */\n constraints?: QueryConstraint[];\n /** Enable realtime updates via onSnapshot (default: false) */\n subscribe?: boolean;\n /** Transform function applied to each document. Receives data WITH id included. */\n select?: (data: DocumentData & { id: string }) => T;\n}\n\n/**\n * Options for infinite scroll queries.\n */\nexport interface FirestoreInfiniteOptions<T> extends FirestoreBaseOptions {\n /** Collection path */\n collectionPath: string;\n /** Firestore query constraints (where, orderBy - required for pagination) */\n constraints?: QueryConstraint[];\n /** Number of items per page (default: 20) */\n pageSize?: number;\n /** Transform function applied to each document. Receives data WITH id included. */\n select?: (data: DocumentData & { id: string }) => T;\n}\n\n/**\n * Options for paginated queries with page numbers.\n */\nexport interface FirestorePaginatedOptions<T> extends FirestoreBaseOptions {\n /** Collection path */\n collectionPath: string;\n /** Firestore query constraints */\n constraints?: QueryConstraint[];\n /** Number of items per page (default: 10) */\n pageSize?: number;\n /** Initial page number (default: 1) */\n initialPage?: number;\n /** Transform function applied to each document. Receives data WITH id included. */\n select?: (data: DocumentData & { id: string }) => T;\n}\n\n/**\n * Options for count() aggregation queries.\n */\nexport interface FirestoreCountOptions extends FirestoreBaseOptions {\n /** Collection path */\n collectionPath: string;\n /** Firestore query constraints (where, etc.) applied before counting */\n constraints?: QueryConstraint[];\n /** Polling interval in ms (omit for no polling) */\n refetchInterval?: number;\n}\n\n/**\n * Options for a live newest-window + cursor-bridged older-pages list\n * (e.g. chat messages, live comment threads, live activity feeds). The newest\n * page is a realtime onSnapshot window; older pages load on demand via getDocs,\n * seeded from the window's oldest document so there is no gap or re-read.\n */\nexport interface FirestoreLiveInfiniteOptions<T = DocumentData & { id: string }> {\n /** Collection path (slash-joined for nested paths, e.g. 'chats/t1/messages'). */\n collectionPath: string;\n /** React Query cache key. The older-pages query is keyed under `[...queryKey, 'older']`. */\n queryKey: readonly unknown[];\n /** Field both the live window and older pages order by (descending under the hood). */\n orderByField: string;\n /** Equality/filter constraints. Do NOT pass orderBy/limit/startAfter — the hook adds those. */\n constraints?: QueryConstraint[];\n /** Items per page / live-window size (default 20, capped at 100). */\n pageSize?: number;\n /** When false, no listener is opened and no pages are fetched. */\n enabled?: boolean;\n /** Map each raw doc (id included) to the item shape. Default: identity (WithId). */\n select?: (data: DocumentData & { id: string }) => T;\n /** Numeric sort key from a raw doc (id included). Default: reads `orderByField` as a number. */\n getSortValue?: (data: DocumentData & { id: string }) => number;\n /** Output ordering of the merged list. Default 'asc' (oldest → newest). */\n sort?: 'asc' | 'desc';\n}\n\n/**\n * Result page for infinite queries.\n */\nexport interface InfinitePage<T> {\n items: WithId<T>[];\n lastDoc: DocumentSnapshot | null;\n size: number;\n}\n\n/**\n * Result for paginated queries.\n */\nexport interface PaginatedResult<T> {\n items: WithId<T>[];\n cursors: DocumentSnapshot[];\n hasMore: boolean;\n}\n\n/**\n * Mutation operation types for batch mutations.\n */\nexport type MutationOperation =\n | { type: 'set'; docPath: string; data: DocumentData; merge?: boolean }\n | { type: 'update'; docPath: string; data: DocumentData }\n | { type: 'delete'; docPath: string };\n\n/**\n * Options for document mutations.\n */\nexport interface FirestoreMutationOptions<T> {\n /** Document path for single-doc mutations */\n docPath?: string;\n /** Query keys to invalidate on success */\n invalidateKeys?: readonly unknown[][];\n /** Enable optimistic updates */\n optimistic?: {\n /** Query key of the cache to update */\n queryKey: readonly unknown[];\n /** How to update the cached data */\n updater: (oldData: T | undefined, newData: Partial<T>) => T;\n };\n}\n\n/**\n * Options for batch mutations.\n */\nexport interface FirestoreBatchOptions {\n /** Query keys to invalidate on success */\n invalidateKeys?: readonly unknown[][];\n /** Max operations per batch (default: 450) */\n batchSize?: number;\n}\n"]}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/firestore/types.ts"],"names":[],"mappings":"AA8CA;;GAEG;AACH,MAAM,UAAU,SAAS,CACvB,IAA8B;IAE9B,OAAO,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE,GAAG,IAAI,CAAC,IAAI,EAAE,EAAE,CAAC;AACzC,CAAC","sourcesContent":["import type {\n DocumentData,\n DocumentSnapshot,\n QueryDocumentSnapshot,\n QueryConstraint,\n} from 'firebase/firestore';\n\n/**\n * Document with ID included in the data object.\n */\nexport type WithId<T> = T & { id: string };\n\n/**\n * Metadata-derived liveness of a realtime subscription. Firestore serves cached\n * snapshots offline WITHOUT firing the error callback, so error-only detection\n * renders a stale cached empty result as \"live\". This four-state is derived from\n * `snapshot.metadata.fromCache` (server-confirmed vs cache) plus the listener\n * error callback:\n * - `connecting` — no server-confirmed snapshot yet (initial, or only cache-first data).\n * - `live` — at least one server-confirmed snapshot (`fromCache === false`).\n * - `offline` — only cached data after having been live (connectivity loss).\n * - `error` — the listener error callback fired (e.g. permission loss).\n *\n * Meaningful only for `subscribe: true`; a non-subscribed hook stays `connecting`.\n */\nexport type FirestoreSourceState = 'connecting' | 'live' | 'offline' | 'error';\n\n/** A query-hook result augmented with the metadata-derived subscription source state. */\nexport type WithSourceState<R> = R & { sourceState: FirestoreSourceState };\n\n/**\n * One requested id's answer from `useBatchFirestoreDocs`:\n * - `loading` — no answer yet (nothing cached for the id, or the hook is disabled).\n * - `present` — the document exists and is readable; `data` is it.\n * - `absent` — the read succeeded and the document does not exist.\n * - `failed` — the read (or, in subscribe mode, the listener) errored; `error` is the cause.\n * A failure is never reported as `absent`, and a document cached before the failure is\n * not reported as `present`. What a failure means for a given collection (a rules denial\n * that stands for \"not there for this reader\", say) is the consumer's to decide from `error`.\n */\nexport type BatchFirestoreDocOutcome<T> =\n | { status: 'loading' }\n | { status: 'present'; data: T }\n | { status: 'absent' }\n | { status: 'failed'; error: Error };\n\n/**\n * Extract document data with ID from a QueryDocumentSnapshot.\n */\nexport function docWithId<T extends DocumentData>(\n snap: QueryDocumentSnapshot<T>\n): WithId<T> {\n return { id: snap.id, ...snap.data() };\n}\n\n/**\n * Base options shared across all Firestore hooks.\n */\nexport interface FirestoreBaseOptions {\n /** React Query cache key */\n queryKey: readonly unknown[];\n /** Enable/disable the query */\n enabled?: boolean;\n /** Stale time override (ms) */\n staleTime?: number;\n /** Garbage collection time override (ms) */\n gcTime?: number;\n}\n\n/**\n * Options for single document queries.\n */\nexport interface FirestoreDocOptions<T> extends FirestoreBaseOptions {\n /** Full document path (e.g., 'users/abc123') */\n docPath: string;\n /** Enable realtime updates via onSnapshot (default: false) */\n subscribe?: boolean;\n /** Transform function applied to document data. Receives data WITH id included. */\n select?: (data: DocumentData & { id: string }) => T;\n}\n\n/**\n * Options for collection queries.\n */\nexport interface FirestoreCollectionOptions<T> extends FirestoreBaseOptions {\n /** Collection path (e.g., 'users' or 'entities/abc/members') */\n collectionPath: string;\n /** Firestore query constraints (where, orderBy, etc.) */\n constraints?: QueryConstraint[];\n /** Enable realtime updates via onSnapshot (default: false) */\n subscribe?: boolean;\n /** Transform function applied to each document. Receives data WITH id included. */\n select?: (data: DocumentData & { id: string }) => T;\n}\n\n/**\n * Options for infinite scroll queries.\n */\nexport interface FirestoreInfiniteOptions<T> extends FirestoreBaseOptions {\n /** Collection path */\n collectionPath: string;\n /** Firestore query constraints (where, orderBy - required for pagination) */\n constraints?: QueryConstraint[];\n /** Number of items per page (default: 20) */\n pageSize?: number;\n /** Transform function applied to each document. Receives data WITH id included. */\n select?: (data: DocumentData & { id: string }) => T;\n}\n\n/**\n * Options for paginated queries with page numbers.\n */\nexport interface FirestorePaginatedOptions<T> extends FirestoreBaseOptions {\n /** Collection path */\n collectionPath: string;\n /** Firestore query constraints */\n constraints?: QueryConstraint[];\n /** Number of items per page (default: 10) */\n pageSize?: number;\n /** Initial page number (default: 1) */\n initialPage?: number;\n /** Transform function applied to each document. Receives data WITH id included. */\n select?: (data: DocumentData & { id: string }) => T;\n}\n\n/**\n * Options for count() aggregation queries.\n */\nexport interface FirestoreCountOptions extends FirestoreBaseOptions {\n /** Collection path */\n collectionPath: string;\n /** Firestore query constraints (where, etc.) applied before counting */\n constraints?: QueryConstraint[];\n /** Polling interval in ms (omit for no polling) */\n refetchInterval?: number;\n}\n\n/**\n * Options for a live newest-window + cursor-bridged older-pages list\n * (e.g. chat messages, live comment threads, live activity feeds). The newest\n * page is a realtime onSnapshot window; older pages load on demand via getDocs,\n * seeded from the window's oldest document so there is no gap or re-read.\n */\nexport interface FirestoreLiveInfiniteOptions<T = DocumentData & { id: string }> {\n /** Collection path (slash-joined for nested paths, e.g. 'chats/t1/messages'). */\n collectionPath: string;\n /** React Query cache key. The older-pages query is keyed under `[...queryKey, 'older']`. */\n queryKey: readonly unknown[];\n /** Field both the live window and older pages order by (descending under the hood). */\n orderByField: string;\n /** Equality/filter constraints. Do NOT pass orderBy/limit/startAfter — the hook adds those. */\n constraints?: QueryConstraint[];\n /** Items per page / live-window size (default 20, capped at 100). */\n pageSize?: number;\n /** When false, no listener is opened and no pages are fetched. */\n enabled?: boolean;\n /** Map each raw doc (id included) to the item shape. Default: identity (WithId). */\n select?: (data: DocumentData & { id: string }) => T;\n /** Numeric sort key from a raw doc (id included). Default: reads `orderByField` as a number. */\n getSortValue?: (data: DocumentData & { id: string }) => number;\n /** Output ordering of the merged list. Default 'asc' (oldest → newest). */\n sort?: 'asc' | 'desc';\n}\n\n/**\n * Result page for infinite queries.\n */\nexport interface InfinitePage<T> {\n items: WithId<T>[];\n lastDoc: DocumentSnapshot | null;\n size: number;\n}\n\n/**\n * Result for paginated queries.\n */\nexport interface PaginatedResult<T> {\n items: WithId<T>[];\n cursors: DocumentSnapshot[];\n hasMore: boolean;\n}\n\n/**\n * Mutation operation types for batch mutations.\n */\nexport type MutationOperation =\n | { type: 'set'; docPath: string; data: DocumentData; merge?: boolean }\n | { type: 'update'; docPath: string; data: DocumentData }\n | { type: 'delete'; docPath: string };\n\n/**\n * Options for document mutations.\n */\nexport interface FirestoreMutationOptions<T> {\n /** Document path for single-doc mutations */\n docPath?: string;\n /** Query keys to invalidate on success */\n invalidateKeys?: readonly unknown[][];\n /** Enable optimistic updates */\n optimistic?: {\n /** Query key of the cache to update */\n queryKey: readonly unknown[];\n /** How to update the cached data */\n updater: (oldData: T | undefined, newData: Partial<T>) => T;\n };\n}\n\n/**\n * Options for batch mutations.\n */\nexport interface FirestoreBatchOptions {\n /** Query keys to invalidate on success */\n invalidateKeys?: readonly unknown[][];\n /** Max operations per batch (default: 450) */\n batchSize?: number;\n}\n"]}
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { STALE_TIMES } from './defaults.js';
2
2
  export { invalidateByPrefix, removeByPrefix, updateQueryData } from './cache-helpers.js';
3
- export type { WithId, FirestoreSourceState, WithSourceState, FirestoreBaseOptions, FirestoreDocOptions, FirestoreCollectionOptions, FirestoreInfiniteOptions, FirestorePaginatedOptions, InfinitePage, PaginatedResult, MutationOperation, FirestoreMutationOptions, FirestoreBatchOptions, } from './firestore/types.js';
3
+ export type { WithId, FirestoreSourceState, WithSourceState, BatchFirestoreDocOutcome, FirestoreBaseOptions, FirestoreDocOptions, FirestoreCollectionOptions, FirestoreInfiniteOptions, FirestorePaginatedOptions, InfinitePage, PaginatedResult, MutationOperation, FirestoreMutationOptions, FirestoreBatchOptions, } from './firestore/types.js';
4
4
  export { docWithId } from './firestore/types.js';
5
5
  export { flattenInfiniteData, getInfiniteDataCount, } from './firestore/infinite-helpers.js';
6
6
  export type { FirestoreSearchConfig, FirestoreSearchOptions, SearchEqualityFilter, } from './search/types.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAG5C,OAAO,EAAE,kBAAkB,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAGzF,YAAY,EACV,MAAM,EACN,oBAAoB,EACpB,eAAe,EACf,oBAAoB,EACpB,mBAAmB,EACnB,0BAA0B,EAC1B,wBAAwB,EACxB,yBAAyB,EACzB,YAAY,EACZ,eAAe,EACf,iBAAiB,EACjB,wBAAwB,EACxB,qBAAqB,GACtB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACjD,OAAO,EACL,mBAAmB,EACnB,oBAAoB,GACrB,MAAM,iCAAiC,CAAC;AAGzC,YAAY,EACV,qBAAqB,EACrB,sBAAsB,EACtB,oBAAoB,GACrB,MAAM,mBAAmB,CAAC;AAG3B,OAAO,EACL,4BAA4B,EAC5B,KAAK,EACL,MAAM,EACN,SAAS,EACT,qBAAqB,EACrB,kBAAkB,GACnB,MAAM,0BAA0B,CAAC;AAClC,YAAY,EACV,sBAAsB,EACtB,+BAA+B,EAC/B,iBAAiB,EACjB,WAAW,GACZ,MAAM,0BAA0B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAG5C,OAAO,EAAE,kBAAkB,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAGzF,YAAY,EACV,MAAM,EACN,oBAAoB,EACpB,eAAe,EACf,wBAAwB,EACxB,oBAAoB,EACpB,mBAAmB,EACnB,0BAA0B,EAC1B,wBAAwB,EACxB,yBAAyB,EACzB,YAAY,EACZ,eAAe,EACf,iBAAiB,EACjB,wBAAwB,EACxB,qBAAqB,GACtB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACjD,OAAO,EACL,mBAAmB,EACnB,oBAAoB,GACrB,MAAM,iCAAiC,CAAC;AAGzC,YAAY,EACV,qBAAqB,EACrB,sBAAsB,EACtB,oBAAoB,GACrB,MAAM,mBAAmB,CAAC;AAG3B,OAAO,EACL,4BAA4B,EAC5B,KAAK,EACL,MAAM,EACN,SAAS,EACT,qBAAqB,EACrB,kBAAkB,GACnB,MAAM,0BAA0B,CAAC;AAClC,YAAY,EACV,sBAAsB,EACtB,+BAA+B,EAC/B,iBAAiB,EACjB,WAAW,GACZ,MAAM,0BAA0B,CAAC"}
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,uFAAuF;AACvF,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAE5C,gBAAgB;AAChB,OAAO,EAAE,kBAAkB,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAkBzF,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACjD,OAAO,EACL,mBAAmB,EACnB,oBAAoB,GACrB,MAAM,iCAAiC,CAAC;AASzC,iFAAiF;AACjF,OAAO,EACL,4BAA4B,EAC5B,KAAK,EACL,MAAM,EACN,SAAS,EACT,qBAAqB,EACrB,kBAAkB,GACnB,MAAM,0BAA0B,CAAC","sourcesContent":["// Query defaults & staleTime presets (server-safe — plain constants, no React runtime)\nexport { STALE_TIMES } from './defaults.js';\n\n// Cache Helpers\nexport { invalidateByPrefix, removeByPrefix, updateQueryData } from './cache-helpers.js';\n\n// Firestore Types & Pure Helpers\nexport type {\n WithId,\n FirestoreSourceState,\n WithSourceState,\n FirestoreBaseOptions,\n FirestoreDocOptions,\n FirestoreCollectionOptions,\n FirestoreInfiniteOptions,\n FirestorePaginatedOptions,\n InfinitePage,\n PaginatedResult,\n MutationOperation,\n FirestoreMutationOptions,\n FirestoreBatchOptions,\n} from './firestore/types.js';\nexport { docWithId } from './firestore/types.js';\nexport {\n flattenInfiniteData,\n getInfiniteDataCount,\n} from './firestore/infinite-helpers.js';\n\n// Search Types\nexport type {\n FirestoreSearchConfig,\n FirestoreSearchOptions,\n SearchEqualityFilter,\n} from './search/types.js';\n\n// Domain-event invalidator (mechanism — consumers register their event registry)\nexport {\n createDomainEventInvalidator,\n exact,\n prefix,\n predicate,\n serializeInvalidation,\n applyInvalidations,\n} from \"./domain-events/index.js\";\nexport type {\n DomainEventInvalidator,\n DomainEventInvalidationRegistry,\n CacheInvalidation,\n RefetchType,\n} from \"./domain-events/index.js\";\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,uFAAuF;AACvF,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAE5C,gBAAgB;AAChB,OAAO,EAAE,kBAAkB,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAmBzF,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACjD,OAAO,EACL,mBAAmB,EACnB,oBAAoB,GACrB,MAAM,iCAAiC,CAAC;AASzC,iFAAiF;AACjF,OAAO,EACL,4BAA4B,EAC5B,KAAK,EACL,MAAM,EACN,SAAS,EACT,qBAAqB,EACrB,kBAAkB,GACnB,MAAM,0BAA0B,CAAC","sourcesContent":["// Query defaults & staleTime presets (server-safe — plain constants, no React runtime)\nexport { STALE_TIMES } from './defaults.js';\n\n// Cache Helpers\nexport { invalidateByPrefix, removeByPrefix, updateQueryData } from './cache-helpers.js';\n\n// Firestore Types & Pure Helpers\nexport type {\n WithId,\n FirestoreSourceState,\n WithSourceState,\n BatchFirestoreDocOutcome,\n FirestoreBaseOptions,\n FirestoreDocOptions,\n FirestoreCollectionOptions,\n FirestoreInfiniteOptions,\n FirestorePaginatedOptions,\n InfinitePage,\n PaginatedResult,\n MutationOperation,\n FirestoreMutationOptions,\n FirestoreBatchOptions,\n} from './firestore/types.js';\nexport { docWithId } from './firestore/types.js';\nexport {\n flattenInfiniteData,\n getInfiniteDataCount,\n} from './firestore/infinite-helpers.js';\n\n// Search Types\nexport type {\n FirestoreSearchConfig,\n FirestoreSearchOptions,\n SearchEqualityFilter,\n} from './search/types.js';\n\n// Domain-event invalidator (mechanism — consumers register their event registry)\nexport {\n createDomainEventInvalidator,\n exact,\n prefix,\n predicate,\n serializeInvalidation,\n applyInvalidations,\n} from \"./domain-events/index.js\";\nexport type {\n DomainEventInvalidator,\n DomainEventInvalidationRegistry,\n CacheInvalidation,\n RefetchType,\n} from \"./domain-events/index.js\";\n"]}
@@ -9,6 +9,12 @@ import type { QueryClient } from '@tanstack/react-query';
9
9
  * The one-shot `queryFn` consults this after its read and yields to the listener's value.
10
10
  */
11
11
  export declare function isListenerOwned(queryClient: QueryClient, queryKeyPrefix: string, id: string): boolean;
12
+ /**
13
+ * The error of the shared listener that owns `[queryKeyPrefix, id]`, or `null` when no listener
14
+ * owns the key or it has not errored. The listener writes `null` to the key when it errors, so a
15
+ * one-shot reader of the same key consults this before taking that `null` as a missing document.
16
+ */
17
+ export declare function listenerErrorOf(queryClient: QueryClient, queryKeyPrefix: string, id: string): Error | null;
12
18
  export interface SubscribeDocParams {
13
19
  queryClient: QueryClient;
14
20
  db: Firestore;
@@ -19,7 +25,7 @@ export interface SubscribeDocParams {
19
25
  id: string;
20
26
  /** Called whenever a snapshot (data or missing) lands, so the caller can re-render. */
21
27
  onUpdate: () => void;
22
- /** Called on listener error. */
28
+ /** Called on listener error — and at once on joining a listener that has already errored. */
23
29
  onError?: (error: Error) => void;
24
30
  }
25
31
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"doc-subscription-registry.d.ts","sourceRoot":"","sources":["../../../src/react/firestore/doc-subscription-registry.ts"],"names":[],"mappings":"AAEA,OAAO,EAAmB,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAC;AACrE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAwCzD;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC7B,WAAW,EAAE,WAAW,EACxB,cAAc,EAAE,MAAM,EACtB,EAAE,EAAE,MAAM,GACT,OAAO,CAET;AAWD,MAAM,WAAW,kBAAkB;IACjC,WAAW,EAAE,WAAW,CAAC;IACzB,EAAE,EAAE,SAAS,CAAC;IACd,4EAA4E;IAC5E,cAAc,EAAE,MAAM,CAAC;IACvB,qEAAqE;IACrE,cAAc,EAAE,MAAM,CAAC;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,uFAAuF;IACvF,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,gCAAgC;IAChC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;CAClC;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,kBAAkB,GAAG,MAAM,IAAI,CA+CnE;AAED,sEAAsE;AACtE,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,WAAW,GAAG,MAAM,CAEtE"}
1
+ {"version":3,"file":"doc-subscription-registry.d.ts","sourceRoot":"","sources":["../../../src/react/firestore/doc-subscription-registry.ts"],"names":[],"mappings":"AAEA,OAAO,EAAmB,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAC;AACrE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AA8CzD;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC7B,WAAW,EAAE,WAAW,EACxB,cAAc,EAAE,MAAM,EACtB,EAAE,EAAE,MAAM,GACT,OAAO,CAET;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,WAAW,EAAE,WAAW,EACxB,cAAc,EAAE,MAAM,EACtB,EAAE,EAAE,MAAM,GACT,KAAK,GAAG,IAAI,CAEd;AAWD,MAAM,WAAW,kBAAkB;IACjC,WAAW,EAAE,WAAW,CAAC;IACzB,EAAE,EAAE,SAAS,CAAC;IACd,4EAA4E;IAC5E,cAAc,EAAE,MAAM,CAAC;IACvB,qEAAqE;IACrE,cAAc,EAAE,MAAM,CAAC;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,uFAAuF;IACvF,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,6FAA6F;IAC7F,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;CAClC;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,kBAAkB,GAAG,MAAM,IAAI,CAwDnE;AAED,sEAAsE;AACtE,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,WAAW,GAAG,MAAM,CAEtE"}
@@ -16,6 +16,14 @@ function registryKey(queryKeyPrefix, id) {
16
16
  export function isListenerOwned(queryClient, queryKeyPrefix, id) {
17
17
  return registries.get(queryClient)?.has(registryKey(queryKeyPrefix, id)) ?? false;
18
18
  }
19
+ /**
20
+ * The error of the shared listener that owns `[queryKeyPrefix, id]`, or `null` when no listener
21
+ * owns the key or it has not errored. The listener writes `null` to the key when it errors, so a
22
+ * one-shot reader of the same key consults this before taking that `null` as a missing document.
23
+ */
24
+ export function listenerErrorOf(queryClient, queryKeyPrefix, id) {
25
+ return registries.get(queryClient)?.get(registryKey(queryKeyPrefix, id))?.error ?? null;
26
+ }
19
27
  function getRegistry(queryClient) {
20
28
  let registry = registries.get(queryClient);
21
29
  if (!registry) {
@@ -35,26 +43,31 @@ export function subscribeDoc(params) {
35
43
  const key = registryKey(queryKeyPrefix, id);
36
44
  let entry = registry.get(key);
37
45
  if (!entry) {
38
- const subscribers = new Set();
46
+ const created = { unsubscribe: () => { }, subscribers: new Set(), error: null };
47
+ const subscribers = created.subscribers;
39
48
  const ref = doc(db, collectionPath, id);
40
- const unsubscribe = onSnapshot(ref, (snapshot) => {
49
+ created.unsubscribe = onSnapshot(ref, (snapshot) => {
50
+ created.error = null;
41
51
  queryClient.setQueryData([queryKeyPrefix, id], snapshot.exists() ? { id: snapshot.id, ...snapshot.data() } : null, { updatedAt: Date.now() });
42
52
  // Copy before iterating: an onUpdate could (re-entrantly) unsubscribe.
43
53
  for (const sub of [...subscribers])
44
54
  sub.onUpdate();
45
55
  }, (error) => {
46
- // Negative-cache the id: a denied doc is resolved-absent. Without this,
47
- // subscribe-mode loading state ("no cache entry yet") would stay true
48
- // forever for ids whose listener can never land a snapshot.
56
+ // Write `null` so the id resolves — as a failure, through the kept error — instead
57
+ // of loading forever: subscribe-mode loading ("no cache entry yet") would otherwise
58
+ // stay true for ids whose listener can never land a snapshot.
59
+ created.error = error;
49
60
  queryClient.setQueryData([queryKeyPrefix, id], null, { updatedAt: Date.now() });
50
61
  for (const sub of [...subscribers])
51
62
  sub.onError?.(error);
52
63
  });
53
- entry = { unsubscribe, subscribers };
64
+ entry = created;
54
65
  registry.set(key, entry);
55
66
  }
56
67
  const subscriber = { onUpdate, onError };
57
68
  entry.subscribers.add(subscriber);
69
+ if (entry.error)
70
+ onError?.(entry.error);
58
71
  let released = false;
59
72
  return () => {
60
73
  if (released)
@@ -67,6 +80,11 @@ export function subscribeDoc(params) {
67
80
  if (current.subscribers.size === 0) {
68
81
  current.unsubscribe();
69
82
  registry.delete(key);
83
+ // A failed listener's `null` is a placeholder, not a read: once nothing owns the key,
84
+ // mark it stale so a remaining one-shot reader reads the document for itself.
85
+ if (current.error) {
86
+ void queryClient.invalidateQueries({ queryKey: [queryKeyPrefix, id], exact: true });
87
+ }
70
88
  }
71
89
  };
72
90
  }
@@ -1 +1 @@
1
- {"version":3,"file":"doc-subscription-registry.js","sourceRoot":"","sources":["../../../src/react/firestore/doc-subscription-registry.ts"],"names":[],"mappings":"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","sourcesContent":["'use client';\n\nimport { doc, onSnapshot, type Firestore } from 'firebase/firestore';\nimport type { QueryClient } from '@tanstack/react-query';\n\n/**\n * Reference-counted, real-time document subscription registry.\n *\n * Backs the `subscribe` mode of {@link useBatchFirestoreDocs}. The problem it solves:\n * many components on a page resolve the SAME document id (e.g. the same author rendered\n * in a feed card, a comment, and a mention). Without sharing, each would open its own\n * `onSnapshot` listener. This registry keeps exactly ONE Firestore listener per\n * `(queryClient, queryKeyPrefix, id)` and fans its updates out to every subscriber, so N\n * components showing the same id cost one listener.\n *\n * The single listener writes the document straight into the React Query cache at\n * `[queryKeyPrefix, id]` — `{ id, ...data }` when the doc exists, or `null` when it does\n * not (negative caching, so a missing/lagging doc resolves the instant it appears instead\n * of staying blank until staleTime). A listener ERROR also negative-caches the id: a\n * rules-denied doc (hidden, or non-existent when the rule references `resource.data`) is\n * resolved-absent, not forever-loading — consumers see `data: null` plus the error instead\n * of an eternal spinner. The registry is keyed per `QueryClient` (a WeakMap), which in\n * practice means per browser tab, so each tab listens independently and stays consistent\n * without cross-tab plumbing.\n */\n\ntype Subscriber = {\n onUpdate: () => void;\n onError?: (error: Error) => void;\n};\n\ntype Entry = {\n unsubscribe: () => void;\n subscribers: Set<Subscriber>;\n};\n\n// Per-QueryClient registry. WeakMap so a discarded QueryClient (and its entries) is GC'd.\nconst registries = new WeakMap<QueryClient, Map<string, Entry>>();\n\nfunction registryKey(queryKeyPrefix: string, id: string): string {\n return `${queryKeyPrefix} ${id}`;\n}\n\n/**\n * True while a shared listener owns `[queryKeyPrefix, id]` for this client.\n *\n * A one-shot read that was already in flight when a consumer entered subscribe mode would\n * otherwise resolve AFTER the listener's first snapshot and overwrite it — and `onSnapshot`\n * does not re-emit until the document actually changes, so the stale value would stick.\n * The one-shot `queryFn` consults this after its read and yields to the listener's value.\n */\nexport function isListenerOwned(\n queryClient: QueryClient,\n queryKeyPrefix: string,\n id: string,\n): boolean {\n return registries.get(queryClient)?.has(registryKey(queryKeyPrefix, id)) ?? false;\n}\n\nfunction getRegistry(queryClient: QueryClient): Map<string, Entry> {\n let registry = registries.get(queryClient);\n if (!registry) {\n registry = new Map();\n registries.set(queryClient, registry);\n }\n return registry;\n}\n\nexport interface SubscribeDocParams {\n queryClient: QueryClient;\n db: Firestore;\n /** Top-level/full collection path the doc lives in (e.g. 'publicUsers'). */\n collectionPath: string;\n /** Cache key prefix; the doc is cached at `[queryKeyPrefix, id]`. */\n queryKeyPrefix: string;\n id: string;\n /** Called whenever a snapshot (data or missing) lands, so the caller can re-render. */\n onUpdate: () => void;\n /** Called on listener error. */\n onError?: (error: Error) => void;\n}\n\n/**\n * Subscribe to a single document via a shared, reference-counted listener.\n * Returns an idempotent unsubscribe function; the underlying Firestore listener is\n * detached only when the last subscriber for that id unsubscribes.\n */\nexport function subscribeDoc(params: SubscribeDocParams): () => void {\n const { queryClient, db, collectionPath, queryKeyPrefix, id, onUpdate, onError } = params;\n const registry = getRegistry(queryClient);\n const key = registryKey(queryKeyPrefix, id);\n\n let entry = registry.get(key);\n if (!entry) {\n const subscribers = new Set<Subscriber>();\n const ref = doc(db, collectionPath, id);\n const unsubscribe = onSnapshot(\n ref,\n (snapshot) => {\n queryClient.setQueryData(\n [queryKeyPrefix, id],\n snapshot.exists() ? { id: snapshot.id, ...snapshot.data() } : null,\n { updatedAt: Date.now() },\n );\n // Copy before iterating: an onUpdate could (re-entrantly) unsubscribe.\n for (const sub of [...subscribers]) sub.onUpdate();\n },\n (error) => {\n // Negative-cache the id: a denied doc is resolved-absent. Without this,\n // subscribe-mode loading state (\"no cache entry yet\") would stay true\n // forever for ids whose listener can never land a snapshot.\n queryClient.setQueryData([queryKeyPrefix, id], null, { updatedAt: Date.now() });\n for (const sub of [...subscribers]) sub.onError?.(error);\n },\n );\n entry = { unsubscribe, subscribers };\n registry.set(key, entry);\n }\n\n const subscriber: Subscriber = { onUpdate, onError };\n entry.subscribers.add(subscriber);\n\n let released = false;\n return () => {\n if (released) return;\n released = true;\n const current = registry.get(key);\n if (!current) return;\n current.subscribers.delete(subscriber);\n if (current.subscribers.size === 0) {\n current.unsubscribe();\n registry.delete(key);\n }\n };\n}\n\n/** Test-only: number of active shared listeners for a QueryClient. */\nexport function __activeListenerCount(queryClient: QueryClient): number {\n return registries.get(queryClient)?.size ?? 0;\n}\n"]}
1
+ {"version":3,"file":"doc-subscription-registry.js","sourceRoot":"","sources":["../../../src/react/firestore/doc-subscription-registry.ts"],"names":[],"mappings":"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","sourcesContent":["'use client';\n\nimport { doc, onSnapshot, type Firestore } from 'firebase/firestore';\nimport type { QueryClient } from '@tanstack/react-query';\n\n/**\n * Reference-counted, real-time document subscription registry.\n *\n * Backs the `subscribe` mode of {@link useBatchFirestoreDocs}. The problem it solves:\n * many components on a page resolve the SAME document id (e.g. the same author rendered\n * in a feed card, a comment, and a mention). Without sharing, each would open its own\n * `onSnapshot` listener. This registry keeps exactly ONE Firestore listener per\n * `(queryClient, queryKeyPrefix, id)` and fans its updates out to every subscriber, so N\n * components showing the same id cost one listener.\n *\n * The single listener writes the document straight into the React Query cache at\n * `[queryKeyPrefix, id]` — `{ id, ...data }` when the doc exists, or `null` when it does\n * not (negative caching, so a missing/lagging doc resolves the instant it appears instead\n * of staying blank until staleTime). A listener ERROR also negative-caches the id: a\n * rules-denied doc (hidden, or non-existent when the rule references `resource.data`) is\n * resolved, not forever-loading — consumers see `data: null` plus the error instead\n * of an eternal spinner. Firestore ends a listener that errors, so the entry keeps that error\n * for as long as the entry lives (until its last subscriber leaves; a later snapshot, were one\n * to arrive, clears it): a subscriber that joins an errored listener receives the error too,\n * and a one-shot reader of the same key reads it through `listenerErrorOf`, so neither takes\n * the cached `null` as a missing document. The registry is keyed per `QueryClient` (a WeakMap),\n * which in practice means per browser tab, so each tab listens independently and stays\n * consistent without cross-tab plumbing.\n */\n\ntype Subscriber = {\n onUpdate: () => void;\n onError?: (error: Error) => void;\n};\n\ntype Entry = {\n unsubscribe: () => void;\n subscribers: Set<Subscriber>;\n /** The listener's last error, cleared by the next snapshot. */\n error: Error | null;\n};\n\n// Per-QueryClient registry. WeakMap so a discarded QueryClient (and its entries) is GC'd.\nconst registries = new WeakMap<QueryClient, Map<string, Entry>>();\n\nfunction registryKey(queryKeyPrefix: string, id: string): string {\n return `${queryKeyPrefix} ${id}`;\n}\n\n/**\n * True while a shared listener owns `[queryKeyPrefix, id]` for this client.\n *\n * A one-shot read that was already in flight when a consumer entered subscribe mode would\n * otherwise resolve AFTER the listener's first snapshot and overwrite it — and `onSnapshot`\n * does not re-emit until the document actually changes, so the stale value would stick.\n * The one-shot `queryFn` consults this after its read and yields to the listener's value.\n */\nexport function isListenerOwned(\n queryClient: QueryClient,\n queryKeyPrefix: string,\n id: string,\n): boolean {\n return registries.get(queryClient)?.has(registryKey(queryKeyPrefix, id)) ?? false;\n}\n\n/**\n * The error of the shared listener that owns `[queryKeyPrefix, id]`, or `null` when no listener\n * owns the key or it has not errored. The listener writes `null` to the key when it errors, so a\n * one-shot reader of the same key consults this before taking that `null` as a missing document.\n */\nexport function listenerErrorOf(\n queryClient: QueryClient,\n queryKeyPrefix: string,\n id: string,\n): Error | null {\n return registries.get(queryClient)?.get(registryKey(queryKeyPrefix, id))?.error ?? null;\n}\n\nfunction getRegistry(queryClient: QueryClient): Map<string, Entry> {\n let registry = registries.get(queryClient);\n if (!registry) {\n registry = new Map();\n registries.set(queryClient, registry);\n }\n return registry;\n}\n\nexport interface SubscribeDocParams {\n queryClient: QueryClient;\n db: Firestore;\n /** Top-level/full collection path the doc lives in (e.g. 'publicUsers'). */\n collectionPath: string;\n /** Cache key prefix; the doc is cached at `[queryKeyPrefix, id]`. */\n queryKeyPrefix: string;\n id: string;\n /** Called whenever a snapshot (data or missing) lands, so the caller can re-render. */\n onUpdate: () => void;\n /** Called on listener error — and at once on joining a listener that has already errored. */\n onError?: (error: Error) => void;\n}\n\n/**\n * Subscribe to a single document via a shared, reference-counted listener.\n * Returns an idempotent unsubscribe function; the underlying Firestore listener is\n * detached only when the last subscriber for that id unsubscribes.\n */\nexport function subscribeDoc(params: SubscribeDocParams): () => void {\n const { queryClient, db, collectionPath, queryKeyPrefix, id, onUpdate, onError } = params;\n const registry = getRegistry(queryClient);\n const key = registryKey(queryKeyPrefix, id);\n\n let entry = registry.get(key);\n if (!entry) {\n const created: Entry = { unsubscribe: () => {}, subscribers: new Set<Subscriber>(), error: null };\n const subscribers = created.subscribers;\n const ref = doc(db, collectionPath, id);\n created.unsubscribe = onSnapshot(\n ref,\n (snapshot) => {\n created.error = null;\n queryClient.setQueryData(\n [queryKeyPrefix, id],\n snapshot.exists() ? { id: snapshot.id, ...snapshot.data() } : null,\n { updatedAt: Date.now() },\n );\n // Copy before iterating: an onUpdate could (re-entrantly) unsubscribe.\n for (const sub of [...subscribers]) sub.onUpdate();\n },\n (error) => {\n // Write `null` so the id resolves — as a failure, through the kept error — instead\n // of loading forever: subscribe-mode loading (\"no cache entry yet\") would otherwise\n // stay true for ids whose listener can never land a snapshot.\n created.error = error;\n queryClient.setQueryData([queryKeyPrefix, id], null, { updatedAt: Date.now() });\n for (const sub of [...subscribers]) sub.onError?.(error);\n },\n );\n entry = created;\n registry.set(key, entry);\n }\n\n const subscriber: Subscriber = { onUpdate, onError };\n entry.subscribers.add(subscriber);\n if (entry.error) onError?.(entry.error);\n\n let released = false;\n return () => {\n if (released) return;\n released = true;\n const current = registry.get(key);\n if (!current) return;\n current.subscribers.delete(subscriber);\n if (current.subscribers.size === 0) {\n current.unsubscribe();\n registry.delete(key);\n // A failed listener's `null` is a placeholder, not a read: once nothing owns the key,\n // mark it stale so a remaining one-shot reader reads the document for itself.\n if (current.error) {\n void queryClient.invalidateQueries({ queryKey: [queryKeyPrefix, id], exact: true });\n }\n }\n };\n}\n\n/** Test-only: number of active shared listeners for a QueryClient. */\nexport function __activeListenerCount(queryClient: QueryClient): number {\n return registries.get(queryClient)?.size ?? 0;\n}\n"]}
@@ -1,4 +1,5 @@
1
1
  import type { Firestore } from 'firebase/firestore';
2
+ import type { BatchFirestoreDocOutcome } from '../../firestore/types.js';
2
3
  /** Transport used to resolve an id in one-shot mode. */
3
4
  export type BatchFirestoreDocsTransport = 'batch' | 'get';
4
5
  export type BatchFirestoreDocsOptions = {
@@ -97,6 +98,14 @@ export type BatchFirestoreDocsResult<T> = {
97
98
  * First error across the requested ids, if any
98
99
  */
99
100
  error: Error | null;
101
+ /**
102
+ * One entry per requested id (empty ids and duplicates dropped): `loading`, `present` with
103
+ * its data, `absent`, or `failed` with that id's own error. A multi-id consumer reads which
104
+ * ids are missing and which failed here — `data` / `isError` / `error` are the aggregate
105
+ * view and cannot tell them apart. The map, and each unchanged entry, keep their identity
106
+ * across renders.
107
+ */
108
+ outcomes: Record<string, BatchFirestoreDocOutcome<T>>;
100
109
  /**
101
110
  * Refetch every requested id, cached ones included. A no-op in subscribe mode.
102
111
  */
@@ -115,6 +124,9 @@ export type BatchFirestoreDocsResult<T> = {
115
124
  * an absent id is then re-read on a bounded ladder (see `absentRetryDelaysMs`) so a doc
116
125
  * that appears shortly afterwards shows up without an invalidation.
117
126
  *
127
+ * Each id's own answer — loading, present, absent, or failed with its own error — is in
128
+ * `outcomes`; the aggregate fields cannot tell an absent id from a failed one.
129
+ *
118
130
  * @example
119
131
  * ```typescript
120
132
  * const { data: users, isLoading } = useBatchFirestoreDocs<PublicUser>({
@@ -1 +1 @@
1
- {"version":3,"file":"useBatchFirestoreDocs.d.ts","sourceRoot":"","sources":["../../../src/react/firestore/useBatchFirestoreDocs.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAKpD,wDAAwD;AACxD,MAAM,MAAM,2BAA2B,GAAG,OAAO,GAAG,KAAK,CAAC;AAE1D,MAAM,MAAM,yBAAyB,GAAG;IACtC;;OAEG;IACH,EAAE,EAAE,SAAS,CAAC;IAEd;;OAEG;IACH,cAAc,EAAE,MAAM,CAAC;IAEvB;;OAEG;IACH,GAAG,EAAE,MAAM,EAAE,CAAC;IAEd;;;;OAIG;IACH,cAAc,EAAE,MAAM,CAAC;IAEvB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;OAEG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAEhB;;OAEG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAElB;;;;;;;;OAQG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IAEpB;;;;;;;;;;;OAWG;IACH,SAAS,CAAC,EAAE,2BAA2B,CAAC;IAExC;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IAEzB;;;;;;;;;;;OAWG;IACH,mBAAmB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACzC,CAAC;AAEF,MAAM,MAAM,wBAAwB,CAAC,CAAC,IAAI;IACxC;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;IAExB;;OAEG;IACH,SAAS,EAAE,OAAO,CAAC;IAEnB;;OAEG;IACH,OAAO,EAAE,OAAO,CAAC;IAEjB;;OAEG;IACH,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IAEpB;;OAEG;IACH,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9B,CAAC;AAWF;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,EACnE,EAAE,EACF,cAAc,EACd,GAAG,EACH,cAAc,EACd,SAA0B,EAC1B,MAAuB,EACvB,OAAc,EACd,SAAiB,EACjB,SAAmB,EACnB,eAA2B,EAC3B,mBAA4C,GAC7C,EAAE,yBAAyB,GAAG,wBAAwB,CAAC,CAAC,CAAC,CA4IzD"}
1
+ {"version":3,"file":"useBatchFirestoreDocs.d.ts","sourceRoot":"","sources":["../../../src/react/firestore/useBatchFirestoreDocs.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAIpD,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,0BAA0B,CAAC;AAEzE,wDAAwD;AACxD,MAAM,MAAM,2BAA2B,GAAG,OAAO,GAAG,KAAK,CAAC;AAE1D,MAAM,MAAM,yBAAyB,GAAG;IACtC;;OAEG;IACH,EAAE,EAAE,SAAS,CAAC;IAEd;;OAEG;IACH,cAAc,EAAE,MAAM,CAAC;IAEvB;;OAEG;IACH,GAAG,EAAE,MAAM,EAAE,CAAC;IAEd;;;;OAIG;IACH,cAAc,EAAE,MAAM,CAAC;IAEvB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;OAEG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAEhB;;OAEG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAElB;;;;;;;;OAQG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IAEpB;;;;;;;;;;;OAWG;IACH,SAAS,CAAC,EAAE,2BAA2B,CAAC;IAExC;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IAEzB;;;;;;;;;;;OAWG;IACH,mBAAmB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACzC,CAAC;AAEF,MAAM,MAAM,wBAAwB,CAAC,CAAC,IAAI;IACxC;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;IAExB;;OAEG;IACH,SAAS,EAAE,OAAO,CAAC;IAEnB;;OAEG;IACH,OAAO,EAAE,OAAO,CAAC;IAEjB;;OAEG;IACH,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IAEpB;;;;;;OAMG;IACH,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,wBAAwB,CAAC,CAAC,CAAC,CAAC,CAAC;IAEtD;;OAEG;IACH,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9B,CAAC;AAqBF;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,EACnE,EAAE,EACF,cAAc,EACd,GAAG,EACH,cAAc,EACd,SAA0B,EAC1B,MAAuB,EACvB,OAAc,EACd,SAAiB,EACjB,SAAmB,EACnB,eAA2B,EAC3B,mBAA4C,GAC7C,EAAE,yBAAyB,GAAG,wBAAwB,CAAC,CAAC,CAAC,CAoLzD"}
@@ -1,9 +1,20 @@
1
1
  'use client';
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { useQueries, useQueryClient } from '@tanstack/react-query';
4
- import { isListenerOwned, subscribeDoc } from './doc-subscription-registry.js';
4
+ import { isListenerOwned, listenerErrorOf, subscribeDoc } from './doc-subscription-registry.js';
5
5
  import { getDocLoader } from './batch-doc-loader.js';
6
6
  import { ABSENT_RETRY_DELAYS_MS, trackAbsentDoc } from './absence-scheduler.js';
7
+ const LOADING_OUTCOME = { status: 'loading' };
8
+ const ABSENT_OUTCOME = { status: 'absent' };
9
+ function sameOutcome(a, b) {
10
+ if (a.status !== b.status)
11
+ return false;
12
+ if (a.status === 'present')
13
+ return a.data === b.data;
14
+ if (a.status === 'failed')
15
+ return a.error === b.error;
16
+ return true;
17
+ }
7
18
  function shallowRecordEqual(a, b) {
8
19
  const aKeys = Object.keys(a);
9
20
  if (aKeys.length !== Object.keys(b).length)
@@ -27,6 +38,9 @@ function shallowRecordEqual(a, b) {
27
38
  * an absent id is then re-read on a bounded ladder (see `absentRetryDelaysMs`) so a doc
28
39
  * that appears shortly afterwards shows up without an invalidation.
29
40
  *
41
+ * Each id's own answer — loading, present, absent, or failed with its own error — is in
42
+ * `outcomes`; the aggregate fields cannot tell an absent id from a failed one.
43
+ *
30
44
  * @example
31
45
  * ```typescript
32
46
  * const { data: users, isLoading } = useBatchFirestoreDocs<PublicUser>({
@@ -50,8 +64,8 @@ export function useBatchFirestoreDocs({ db, collectionPath, ids, queryKeyPrefix,
50
64
  }, [idsKey]);
51
65
  // ── Real-time subscribe mode ──────────────────────────────────────────────
52
66
  // Listener errors cannot surface through the per-id queries (they are disabled while
53
- // subscribed), so they are tracked here and merged into the returned result.
54
- const [subscribeError, setSubscribeError] = useState(null);
67
+ // subscribed), so they are tracked here, per id, and merged into the returned result.
68
+ const [subscribeErrors, setSubscribeErrors] = useState({});
55
69
  // Snapshot re-render trigger. The per-id query observers do notify on the listener's
56
70
  // cache write, but only on React Query's batched microtask; bumping state in the
57
71
  // snapshot callback keeps a landed snapshot visible in the SAME tick it arrives.
@@ -64,8 +78,12 @@ export function useBatchFirestoreDocs({ db, collectionPath, ids, queryKeyPrefix,
64
78
  const loaded = transport === 'get' ? await loader.loadOne(id, signal) : await loader.loadBatched(id, signal);
65
79
  // If a shared listener took ownership of this key while the read was in flight, the
66
80
  // listener's value is the newer truth — `onSnapshot` will not re-emit until the doc
67
- // changes, so returning the read result here would leave a stale value stuck.
81
+ // changes, so returning the read result here would leave a stale value stuck. A listener
82
+ // that errored left only a `null` placeholder; its error is the answer, never an absence.
68
83
  if (isListenerOwned(queryClient, queryKeyPrefix, id)) {
84
+ const listenerError = listenerErrorOf(queryClient, queryKeyPrefix, id);
85
+ if (listenerError)
86
+ throw listenerError;
69
87
  const owned = queryClient.getQueryData([queryKeyPrefix, id]);
70
88
  if (owned !== undefined)
71
89
  return owned;
@@ -86,15 +104,23 @@ export function useBatchFirestoreDocs({ db, collectionPath, ids, queryKeyPrefix,
86
104
  useEffect(() => {
87
105
  if (!subscribe || !enabled || uniqueIds.length === 0)
88
106
  return;
89
- setSubscribeError(null);
107
+ setSubscribeErrors({});
90
108
  const unsubscribers = uniqueIds.map((id) => subscribeDoc({
91
109
  queryClient,
92
110
  db,
93
111
  collectionPath,
94
112
  queryKeyPrefix,
95
113
  id,
96
- onUpdate: () => setSnapshotVersion((version) => version + 1),
97
- onError: (error) => setSubscribeError(error),
114
+ onUpdate: () => {
115
+ setSubscribeErrors((errors) => {
116
+ if (!(id in errors))
117
+ return errors;
118
+ const { [id]: _cleared, ...rest } = errors;
119
+ return rest;
120
+ });
121
+ setSnapshotVersion((version) => version + 1);
122
+ },
123
+ onError: (error) => setSubscribeErrors((errors) => (errors[id] === error ? errors : { ...errors, [id]: error })),
98
124
  }));
99
125
  return () => {
100
126
  for (const unsubscribe of unsubscribers)
@@ -115,36 +141,63 @@ export function useBatchFirestoreDocs({ db, collectionPath, ids, queryKeyPrefix,
115
141
  release();
116
142
  };
117
143
  }, [uniqueIds, queryKeyPrefix, queryClient, absentDelays]);
144
+ // The maps are rebuilt every render; the committed refs below hand back the last
145
+ // COMMITTED objects while nothing changed, so consumers can depend on their identity. Only
146
+ // the effect writes the refs, so a concurrent render React discards can never leave them
147
+ // pointing at an uncommitted map.
148
+ const committedDataRef = useRef({});
149
+ const committedOutcomesRef = useRef({});
118
150
  const nextData = {};
151
+ const nextOutcomes = {};
119
152
  let anyError = null;
153
+ let anyListenerError = null;
120
154
  let oneShotLoading = false;
121
155
  let anyUnresolved = false;
122
156
  for (let index = 0; index < uniqueIds.length; index += 1) {
157
+ const id = uniqueIds[index];
123
158
  const result = results[index];
124
- if (!result)
125
- continue;
126
- if (result.isError) {
127
- anyError ??= result.error ?? null;
128
- continue;
159
+ let outcome = LOADING_OUTCOME;
160
+ // A one-shot reader of a key a failed shared listener owns sees that listener's `null`
161
+ // placeholder in the cache; the registry's error is what it stands for.
162
+ const listenerError = (subscribe ? subscribeErrors[id] : undefined) ??
163
+ listenerErrorOf(queryClient, queryKeyPrefix, id) ??
164
+ undefined;
165
+ if (result?.isError) {
166
+ const error = result.error;
167
+ anyError ??= error;
168
+ outcome = { status: 'failed', error };
169
+ }
170
+ else if (result) {
171
+ if (result.isLoading)
172
+ oneShotLoading = true;
173
+ const value = result.data;
174
+ if (value === undefined)
175
+ anyUnresolved = true;
176
+ if (listenerError) {
177
+ anyListenerError ??= listenerError;
178
+ outcome = { status: 'failed', error: listenerError };
179
+ }
180
+ else if (value === null) {
181
+ outcome = ABSENT_OUTCOME;
182
+ }
183
+ else if (value !== undefined) {
184
+ nextData[id] = value;
185
+ outcome = { status: 'present', data: value };
186
+ }
129
187
  }
130
- if (result.isLoading)
131
- oneShotLoading = true;
132
- const value = result.data;
133
- if (value === undefined)
134
- anyUnresolved = true;
135
- else if (value)
136
- nextData[uniqueIds[index]] = value;
188
+ const committed = committedOutcomesRef.current[id];
189
+ nextOutcomes[id] = committed && sameOutcome(committed, outcome) ? committed : outcome;
137
190
  }
138
- // The map is rebuilt every render; keep the last COMMITTED object while it is unchanged so
139
- // consumers can safely depend on `data`'s identity. Only the effect writes the ref, so a
140
- // concurrent render React discards can never leave it pointing at an uncommitted map.
141
- const committedDataRef = useRef(nextData);
142
191
  const data = shallowRecordEqual(committedDataRef.current, nextData)
143
192
  ? committedDataRef.current
144
193
  : nextData;
194
+ const outcomes = shallowRecordEqual(committedOutcomesRef.current, nextOutcomes)
195
+ ? committedOutcomesRef.current
196
+ : nextOutcomes;
145
197
  useEffect(() => {
146
198
  committedDataRef.current = data;
147
- }, [data]);
199
+ committedOutcomesRef.current = outcomes;
200
+ }, [data, outcomes]);
148
201
  const refetch = useCallback(async () => {
149
202
  // Subscribe mode owns its freshness through the shared listeners — refetching would
150
203
  // issue reads against disabled queries and race the listener's value.
@@ -158,8 +211,9 @@ export function useBatchFirestoreDocs({ db, collectionPath, ids, queryKeyPrefix,
158
211
  return {
159
212
  data,
160
213
  isLoading,
161
- isError: anyError !== null || subscribeError !== null,
162
- error: anyError ?? subscribeError,
214
+ isError: anyError !== null || anyListenerError !== null,
215
+ error: anyError ?? anyListenerError,
216
+ outcomes,
163
217
  refetch,
164
218
  };
165
219
  }
@@ -1 +1 @@
1
- {"version":3,"file":"useBatchFirestoreDocs.js","sourceRoot":"","sources":["../../../src/react/firestore/useBatchFirestoreDocs.ts"],"names":[],"mappings":"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","sourcesContent":["'use client';\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { useQueries, useQueryClient } from '@tanstack/react-query';\nimport type { Firestore } from 'firebase/firestore';\nimport { isListenerOwned, subscribeDoc } from './doc-subscription-registry.js';\nimport { getDocLoader } from './batch-doc-loader.js';\nimport { ABSENT_RETRY_DELAYS_MS, trackAbsentDoc } from './absence-scheduler.js';\n\n/** Transport used to resolve an id in one-shot mode. */\nexport type BatchFirestoreDocsTransport = 'batch' | 'get';\n\nexport type BatchFirestoreDocsOptions = {\n /**\n * Firestore instance to use for queries\n */\n db: Firestore;\n\n /**\n * Full collection path (e.g., 'publicUsers', 'entities/public')\n */\n collectionPath: string;\n\n /**\n * Array of document IDs to fetch\n */\n ids: string[];\n\n /**\n * Query key prefix for individual document cache entries\n * Each document is cached as [queryKeyPrefix, docId]\n * @example 'publicUser' -> ['publicUser', 'user123']\n */\n queryKeyPrefix: string;\n\n /**\n * Time in milliseconds a PRESENT document stays fresh (default: 30 minutes).\n * An absent document uses `absentStaleTime` instead.\n */\n staleTime?: number;\n\n /**\n * Time in milliseconds to keep unused data in cache (default: 1 hour)\n */\n gcTime?: number;\n\n /**\n * Whether queries should be enabled (default: true)\n */\n enabled?: boolean;\n\n /**\n * Real-time mode (default: false). When true, each id is resolved via a shared,\n * reference-counted `onSnapshot` listener instead of a one-shot read:\n * - a missing doc caches `null` (negative caching) and resolves the instant it appears,\n * - identity edits propagate live, and every browser tab stays consistent,\n * - `staleTime` is irrelevant (listeners keep the cache fresh) and `refetch()` is a no-op.\n * Initial read cost matches the one-shot path; listeners then stay open while mounted.\n * Use for small, frequently-rendered, change-sensitive docs owned by the signed-in user.\n */\n subscribe?: boolean;\n\n /**\n * One-shot transport (default: `'batch'`).\n *\n * - `'batch'` — ids enqueued in the same microtask are coalesced into\n * `where(documentId(), 'in', …)` queries of at most 30 ids each. Fewest round-trips.\n * - `'get'` — one `getDoc` per id, run concurrently. Required for a collection whose\n * Firestore rules gate reads on `resource.data`: an unconstrained id-list query is\n * denied WHOLESALE there, while per-document gets are evaluated per document, so one\n * unreadable id errors alone instead of failing every id with it. On such a collection\n * a get against a nonexistent doc is also denied, so \"absent\" and \"hidden\" are the same\n * client-visible signal — the id surfaces as that query's error, never as a null.\n */\n transport?: BatchFirestoreDocsTransport;\n\n /**\n * Time in milliseconds an ABSENT (`null`) result stays fresh (default: 30 seconds).\n * Deliberately much shorter than `staleTime`: a missing doc is usually a doc that has\n * not been mirrored/created YET, and holding \"missing\" as fresh for the full staleTime\n * is what leaves a row blank long after the doc appears.\n */\n absentStaleTime?: number;\n\n /**\n * Backoff ladder (ms) for re-reading an id that resolved absent, one re-read per rung\n * per absence episode (default: {@link ABSENT_RETRY_DELAYS_MS}). Pass `[]` to ask for no\n * re-reads.\n *\n * The timer is per cache key, so many consumers of the same absent id still cost one\n * re-read per rung — and consumers that disagree combine by UNION, never by mount order:\n * the key polls if ANY mounted consumer wants polling, on the LONGEST ladder among them.\n * `[]` therefore only silences re-reads that no other mounted consumer asked for. The\n * effective ladder is recomputed on every mount and unmount; an episode already in\n * progress continues on the new ladder's remaining rungs, or stops if it is now empty.\n */\n absentRetryDelaysMs?: readonly number[];\n};\n\nexport type BatchFirestoreDocsResult<T> = {\n /**\n * Map of docId -> document data.\n * Only includes documents that exist AND are currently readable: an id whose query is in\n * an error state is excluded, so a previously cached doc is never presented as readable\n * after access to it fails.\n */\n data: Record<string, T>;\n\n /**\n * True while any requested id has not resolved yet\n */\n isLoading: boolean;\n\n /**\n * True if any requested id is in an error state\n */\n isError: boolean;\n\n /**\n * First error across the requested ids, if any\n */\n error: Error | null;\n\n /**\n * Refetch every requested id, cached ones included. A no-op in subscribe mode.\n */\n refetch: () => Promise<void>;\n};\n\nfunction shallowRecordEqual<T>(a: Record<string, T>, b: Record<string, T>): boolean {\n const aKeys = Object.keys(a);\n if (aKeys.length !== Object.keys(b).length) return false;\n for (const key of aKeys) {\n if (!Object.prototype.hasOwnProperty.call(b, key) || a[key] !== b[key]) return false;\n }\n return true;\n}\n\n/**\n * Resolve many document ids into individually observed, individually cached React Query\n * entries at `[queryKeyPrefix, id]`.\n *\n * There is exactly ONE data cache: the per-id entries. Each id owns a real query with a\n * real `queryFn`, so exact-key invalidation, `refetchQueries({ type: 'active' })`, and\n * `refetchOnMount` all work on a single id the way they work on any other query — and the\n * returned `data` is derived reactively from those query results, never read imperatively.\n *\n * A nonexistent document resolves to `null` (negative caching) and is excluded from `data`;\n * an absent id is then re-read on a bounded ladder (see `absentRetryDelaysMs`) so a doc\n * that appears shortly afterwards shows up without an invalidation.\n *\n * @example\n * ```typescript\n * const { data: users, isLoading } = useBatchFirestoreDocs<PublicUser>({\n * db,\n * collectionPath: 'publicUsers',\n * ids: ['user1', 'user2', 'user3'],\n * queryKeyPrefix: 'publicUser',\n * staleTime: 30 * 60 * 1000,\n * });\n * // users = { user1: {...}, user2: {...}, user3: {...} }\n * ```\n */\nexport function useBatchFirestoreDocs<T extends Record<string, any>>({\n db,\n collectionPath,\n ids,\n queryKeyPrefix,\n staleTime = 30 * 60 * 1000,\n gcTime = 60 * 60 * 1000,\n enabled = true,\n subscribe = false,\n transport = 'batch',\n absentStaleTime = 30 * 1000,\n absentRetryDelaysMs = ABSENT_RETRY_DELAYS_MS,\n}: BatchFirestoreDocsOptions): BatchFirestoreDocsResult<T> {\n const queryClient = useQueryClient();\n\n // Stabilize ids reference to prevent cascading re-renders\n const idsKey = JSON.stringify(ids);\n\n // Deduplicate and filter out empty IDs\n const uniqueIds = useMemo(() => {\n const parsed: string[] = JSON.parse(idsKey);\n return Array.from(new Set(parsed.filter(Boolean)));\n }, [idsKey]);\n\n // ── Real-time subscribe mode ──────────────────────────────────────────────\n // Listener errors cannot surface through the per-id queries (they are disabled while\n // subscribed), so they are tracked here and merged into the returned result.\n const [subscribeError, setSubscribeError] = useState<Error | null>(null);\n // Snapshot re-render trigger. The per-id query observers do notify on the listener's\n // cache write, but only on React Query's batched microtask; bumping state in the\n // snapshot callback keeps a landed snapshot visible in the SAME tick it arrives.\n const [, setSnapshotVersion] = useState(0);\n\n const results = useQueries({\n queries: uniqueIds.map((id) => ({\n queryKey: [queryKeyPrefix, id],\n queryFn: async ({ signal }: { signal: AbortSignal }): Promise<T | null> => {\n const loader = getDocLoader(queryClient, db, collectionPath);\n const loaded =\n transport === 'get' ? await loader.loadOne(id, signal) : await loader.loadBatched(id, signal);\n // If a shared listener took ownership of this key while the read was in flight, the\n // listener's value is the newer truth — `onSnapshot` will not re-emit until the doc\n // changes, so returning the read result here would leave a stale value stuck.\n if (isListenerOwned(queryClient, queryKeyPrefix, id)) {\n const owned = queryClient.getQueryData([queryKeyPrefix, id]);\n if (owned !== undefined) return owned as T | null;\n }\n return loaded as T | null;\n },\n enabled: enabled && !subscribe,\n // The consuming app's global default is `refetchOnMount: false`; these lookups opt\n // back in so a remount re-reads a STALE id (refetch-on-mount never fires on a fresh\n // one, so an all-cache-hit mount still costs nothing).\n refetchOnMount: !subscribe,\n staleTime: subscribe\n ? Infinity\n : (query: { state: { data: unknown } }) =>\n query.state.data === null ? absentStaleTime : staleTime,\n gcTime,\n })),\n });\n\n useEffect(() => {\n if (!subscribe || !enabled || uniqueIds.length === 0) return;\n setSubscribeError(null);\n const unsubscribers = uniqueIds.map((id) =>\n subscribeDoc({\n queryClient,\n db,\n collectionPath,\n queryKeyPrefix,\n id,\n onUpdate: () => setSnapshotVersion((version) => version + 1),\n onError: (error) => setSubscribeError(error),\n }),\n );\n return () => {\n for (const unsubscribe of unsubscribers) unsubscribe();\n };\n }, [uniqueIds, subscribe, enabled, collectionPath, queryKeyPrefix, queryClient, db]);\n\n // Bounded re-read ladder for ids that resolved absent. Registration is per cache key, so\n // overlapping consumers share one episode budget. The ladder is rebuilt from its own\n // serialized form so an inline array literal cannot re-register (and reset it) every render.\n const absentDelaysKey = absentRetryDelaysMs.join(',');\n const absentDelays = useMemo(\n () => absentDelaysKey.split(',').filter(Boolean).map(Number),\n [absentDelaysKey],\n );\n\n useEffect(() => {\n if (uniqueIds.length === 0) return;\n const releases = uniqueIds.map((id) =>\n trackAbsentDoc({ queryClient, queryKey: [queryKeyPrefix, id], delays: absentDelays }),\n );\n return () => {\n for (const release of releases) release();\n };\n }, [uniqueIds, queryKeyPrefix, queryClient, absentDelays]);\n\n const nextData: Record<string, T> = {};\n let anyError: Error | null = null;\n let oneShotLoading = false;\n let anyUnresolved = false;\n\n for (let index = 0; index < uniqueIds.length; index += 1) {\n const result = results[index];\n if (!result) continue;\n if (result.isError) {\n anyError ??= (result.error as Error | null) ?? null;\n continue;\n }\n if (result.isLoading) oneShotLoading = true;\n const value = result.data as T | null | undefined;\n if (value === undefined) anyUnresolved = true;\n else if (value) nextData[uniqueIds[index]] = value;\n }\n\n // The map is rebuilt every render; keep the last COMMITTED object while it is unchanged so\n // consumers can safely depend on `data`'s identity. Only the effect writes the ref, so a\n // concurrent render React discards can never leave it pointing at an uncommitted map.\n const committedDataRef = useRef<Record<string, T>>(nextData);\n const data = shallowRecordEqual(committedDataRef.current, nextData)\n ? committedDataRef.current\n : nextData;\n\n useEffect(() => {\n committedDataRef.current = data;\n }, [data]);\n\n const refetch = useCallback(async () => {\n // Subscribe mode owns its freshness through the shared listeners — refetching would\n // issue reads against disabled queries and race the listener's value.\n if (subscribe || !enabled) return;\n await Promise.all(\n uniqueIds.map((id) =>\n queryClient.refetchQueries({ queryKey: [queryKeyPrefix, id], exact: true }),\n ),\n );\n }, [uniqueIds, subscribe, enabled, queryClient, queryKeyPrefix]);\n\n // In subscribe mode \"loading\" means a subscribed id has no cache entry yet (`null`\n // counts as resolved-missing).\n const isLoading = subscribe ? enabled && anyUnresolved : oneShotLoading;\n\n return {\n data,\n isLoading,\n isError: anyError !== null || subscribeError !== null,\n error: anyError ?? subscribeError,\n refetch,\n };\n}\n"]}
1
+ {"version":3,"file":"useBatchFirestoreDocs.js","sourceRoot":"","sources":["../../../src/react/firestore/useBatchFirestoreDocs.ts"],"names":[],"mappings":"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","sourcesContent":["'use client';\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { useQueries, useQueryClient } from '@tanstack/react-query';\nimport type { Firestore } from 'firebase/firestore';\nimport { isListenerOwned, listenerErrorOf, subscribeDoc } from './doc-subscription-registry.js';\nimport { getDocLoader } from './batch-doc-loader.js';\nimport { ABSENT_RETRY_DELAYS_MS, trackAbsentDoc } from './absence-scheduler.js';\nimport type { BatchFirestoreDocOutcome } from '../../firestore/types.js';\n\n/** Transport used to resolve an id in one-shot mode. */\nexport type BatchFirestoreDocsTransport = 'batch' | 'get';\n\nexport type BatchFirestoreDocsOptions = {\n /**\n * Firestore instance to use for queries\n */\n db: Firestore;\n\n /**\n * Full collection path (e.g., 'publicUsers', 'entities/public')\n */\n collectionPath: string;\n\n /**\n * Array of document IDs to fetch\n */\n ids: string[];\n\n /**\n * Query key prefix for individual document cache entries\n * Each document is cached as [queryKeyPrefix, docId]\n * @example 'publicUser' -> ['publicUser', 'user123']\n */\n queryKeyPrefix: string;\n\n /**\n * Time in milliseconds a PRESENT document stays fresh (default: 30 minutes).\n * An absent document uses `absentStaleTime` instead.\n */\n staleTime?: number;\n\n /**\n * Time in milliseconds to keep unused data in cache (default: 1 hour)\n */\n gcTime?: number;\n\n /**\n * Whether queries should be enabled (default: true)\n */\n enabled?: boolean;\n\n /**\n * Real-time mode (default: false). When true, each id is resolved via a shared,\n * reference-counted `onSnapshot` listener instead of a one-shot read:\n * - a missing doc caches `null` (negative caching) and resolves the instant it appears,\n * - identity edits propagate live, and every browser tab stays consistent,\n * - `staleTime` is irrelevant (listeners keep the cache fresh) and `refetch()` is a no-op.\n * Initial read cost matches the one-shot path; listeners then stay open while mounted.\n * Use for small, frequently-rendered, change-sensitive docs owned by the signed-in user.\n */\n subscribe?: boolean;\n\n /**\n * One-shot transport (default: `'batch'`).\n *\n * - `'batch'` — ids enqueued in the same microtask are coalesced into\n * `where(documentId(), 'in', …)` queries of at most 30 ids each. Fewest round-trips.\n * - `'get'` — one `getDoc` per id, run concurrently. Required for a collection whose\n * Firestore rules gate reads on `resource.data`: an unconstrained id-list query is\n * denied WHOLESALE there, while per-document gets are evaluated per document, so one\n * unreadable id errors alone instead of failing every id with it. On such a collection\n * a get against a nonexistent doc is also denied, so \"absent\" and \"hidden\" are the same\n * client-visible signal — the id surfaces as that query's error, never as a null.\n */\n transport?: BatchFirestoreDocsTransport;\n\n /**\n * Time in milliseconds an ABSENT (`null`) result stays fresh (default: 30 seconds).\n * Deliberately much shorter than `staleTime`: a missing doc is usually a doc that has\n * not been mirrored/created YET, and holding \"missing\" as fresh for the full staleTime\n * is what leaves a row blank long after the doc appears.\n */\n absentStaleTime?: number;\n\n /**\n * Backoff ladder (ms) for re-reading an id that resolved absent, one re-read per rung\n * per absence episode (default: {@link ABSENT_RETRY_DELAYS_MS}). Pass `[]` to ask for no\n * re-reads.\n *\n * The timer is per cache key, so many consumers of the same absent id still cost one\n * re-read per rung — and consumers that disagree combine by UNION, never by mount order:\n * the key polls if ANY mounted consumer wants polling, on the LONGEST ladder among them.\n * `[]` therefore only silences re-reads that no other mounted consumer asked for. The\n * effective ladder is recomputed on every mount and unmount; an episode already in\n * progress continues on the new ladder's remaining rungs, or stops if it is now empty.\n */\n absentRetryDelaysMs?: readonly number[];\n};\n\nexport type BatchFirestoreDocsResult<T> = {\n /**\n * Map of docId -> document data.\n * Only includes documents that exist AND are currently readable: an id whose query is in\n * an error state is excluded, so a previously cached doc is never presented as readable\n * after access to it fails.\n */\n data: Record<string, T>;\n\n /**\n * True while any requested id has not resolved yet\n */\n isLoading: boolean;\n\n /**\n * True if any requested id is in an error state\n */\n isError: boolean;\n\n /**\n * First error across the requested ids, if any\n */\n error: Error | null;\n\n /**\n * One entry per requested id (empty ids and duplicates dropped): `loading`, `present` with\n * its data, `absent`, or `failed` with that id's own error. A multi-id consumer reads which\n * ids are missing and which failed here — `data` / `isError` / `error` are the aggregate\n * view and cannot tell them apart. The map, and each unchanged entry, keep their identity\n * across renders.\n */\n outcomes: Record<string, BatchFirestoreDocOutcome<T>>;\n\n /**\n * Refetch every requested id, cached ones included. A no-op in subscribe mode.\n */\n refetch: () => Promise<void>;\n};\n\nconst LOADING_OUTCOME = { status: 'loading' } as const;\nconst ABSENT_OUTCOME = { status: 'absent' } as const;\n\nfunction sameOutcome<T>(a: BatchFirestoreDocOutcome<T>, b: BatchFirestoreDocOutcome<T>): boolean {\n if (a.status !== b.status) return false;\n if (a.status === 'present') return a.data === (b as { data: T }).data;\n if (a.status === 'failed') return a.error === (b as { error: Error }).error;\n return true;\n}\n\nfunction shallowRecordEqual<T>(a: Record<string, T>, b: Record<string, T>): boolean {\n const aKeys = Object.keys(a);\n if (aKeys.length !== Object.keys(b).length) return false;\n for (const key of aKeys) {\n if (!Object.prototype.hasOwnProperty.call(b, key) || a[key] !== b[key]) return false;\n }\n return true;\n}\n\n/**\n * Resolve many document ids into individually observed, individually cached React Query\n * entries at `[queryKeyPrefix, id]`.\n *\n * There is exactly ONE data cache: the per-id entries. Each id owns a real query with a\n * real `queryFn`, so exact-key invalidation, `refetchQueries({ type: 'active' })`, and\n * `refetchOnMount` all work on a single id the way they work on any other query — and the\n * returned `data` is derived reactively from those query results, never read imperatively.\n *\n * A nonexistent document resolves to `null` (negative caching) and is excluded from `data`;\n * an absent id is then re-read on a bounded ladder (see `absentRetryDelaysMs`) so a doc\n * that appears shortly afterwards shows up without an invalidation.\n *\n * Each id's own answer — loading, present, absent, or failed with its own error — is in\n * `outcomes`; the aggregate fields cannot tell an absent id from a failed one.\n *\n * @example\n * ```typescript\n * const { data: users, isLoading } = useBatchFirestoreDocs<PublicUser>({\n * db,\n * collectionPath: 'publicUsers',\n * ids: ['user1', 'user2', 'user3'],\n * queryKeyPrefix: 'publicUser',\n * staleTime: 30 * 60 * 1000,\n * });\n * // users = { user1: {...}, user2: {...}, user3: {...} }\n * ```\n */\nexport function useBatchFirestoreDocs<T extends Record<string, any>>({\n db,\n collectionPath,\n ids,\n queryKeyPrefix,\n staleTime = 30 * 60 * 1000,\n gcTime = 60 * 60 * 1000,\n enabled = true,\n subscribe = false,\n transport = 'batch',\n absentStaleTime = 30 * 1000,\n absentRetryDelaysMs = ABSENT_RETRY_DELAYS_MS,\n}: BatchFirestoreDocsOptions): BatchFirestoreDocsResult<T> {\n const queryClient = useQueryClient();\n\n // Stabilize ids reference to prevent cascading re-renders\n const idsKey = JSON.stringify(ids);\n\n // Deduplicate and filter out empty IDs\n const uniqueIds = useMemo(() => {\n const parsed: string[] = JSON.parse(idsKey);\n return Array.from(new Set(parsed.filter(Boolean)));\n }, [idsKey]);\n\n // ── Real-time subscribe mode ──────────────────────────────────────────────\n // Listener errors cannot surface through the per-id queries (they are disabled while\n // subscribed), so they are tracked here, per id, and merged into the returned result.\n const [subscribeErrors, setSubscribeErrors] = useState<Record<string, Error>>({});\n // Snapshot re-render trigger. The per-id query observers do notify on the listener's\n // cache write, but only on React Query's batched microtask; bumping state in the\n // snapshot callback keeps a landed snapshot visible in the SAME tick it arrives.\n const [, setSnapshotVersion] = useState(0);\n\n const results = useQueries({\n queries: uniqueIds.map((id) => ({\n queryKey: [queryKeyPrefix, id],\n queryFn: async ({ signal }: { signal: AbortSignal }): Promise<T | null> => {\n const loader = getDocLoader(queryClient, db, collectionPath);\n const loaded =\n transport === 'get' ? await loader.loadOne(id, signal) : await loader.loadBatched(id, signal);\n // If a shared listener took ownership of this key while the read was in flight, the\n // listener's value is the newer truth — `onSnapshot` will not re-emit until the doc\n // changes, so returning the read result here would leave a stale value stuck. A listener\n // that errored left only a `null` placeholder; its error is the answer, never an absence.\n if (isListenerOwned(queryClient, queryKeyPrefix, id)) {\n const listenerError = listenerErrorOf(queryClient, queryKeyPrefix, id);\n if (listenerError) throw listenerError;\n const owned = queryClient.getQueryData([queryKeyPrefix, id]);\n if (owned !== undefined) return owned as T | null;\n }\n return loaded as T | null;\n },\n enabled: enabled && !subscribe,\n // The consuming app's global default is `refetchOnMount: false`; these lookups opt\n // back in so a remount re-reads a STALE id (refetch-on-mount never fires on a fresh\n // one, so an all-cache-hit mount still costs nothing).\n refetchOnMount: !subscribe,\n staleTime: subscribe\n ? Infinity\n : (query: { state: { data: unknown } }) =>\n query.state.data === null ? absentStaleTime : staleTime,\n gcTime,\n })),\n });\n\n useEffect(() => {\n if (!subscribe || !enabled || uniqueIds.length === 0) return;\n setSubscribeErrors({});\n const unsubscribers = uniqueIds.map((id) =>\n subscribeDoc({\n queryClient,\n db,\n collectionPath,\n queryKeyPrefix,\n id,\n onUpdate: () => {\n setSubscribeErrors((errors) => {\n if (!(id in errors)) return errors;\n const { [id]: _cleared, ...rest } = errors;\n return rest;\n });\n setSnapshotVersion((version) => version + 1);\n },\n onError: (error) =>\n setSubscribeErrors((errors) => (errors[id] === error ? errors : { ...errors, [id]: error })),\n }),\n );\n return () => {\n for (const unsubscribe of unsubscribers) unsubscribe();\n };\n }, [uniqueIds, subscribe, enabled, collectionPath, queryKeyPrefix, queryClient, db]);\n\n // Bounded re-read ladder for ids that resolved absent. Registration is per cache key, so\n // overlapping consumers share one episode budget. The ladder is rebuilt from its own\n // serialized form so an inline array literal cannot re-register (and reset it) every render.\n const absentDelaysKey = absentRetryDelaysMs.join(',');\n const absentDelays = useMemo(\n () => absentDelaysKey.split(',').filter(Boolean).map(Number),\n [absentDelaysKey],\n );\n\n useEffect(() => {\n if (uniqueIds.length === 0) return;\n const releases = uniqueIds.map((id) =>\n trackAbsentDoc({ queryClient, queryKey: [queryKeyPrefix, id], delays: absentDelays }),\n );\n return () => {\n for (const release of releases) release();\n };\n }, [uniqueIds, queryKeyPrefix, queryClient, absentDelays]);\n\n // The maps are rebuilt every render; the committed refs below hand back the last\n // COMMITTED objects while nothing changed, so consumers can depend on their identity. Only\n // the effect writes the refs, so a concurrent render React discards can never leave them\n // pointing at an uncommitted map.\n const committedDataRef = useRef<Record<string, T>>({});\n const committedOutcomesRef = useRef<Record<string, BatchFirestoreDocOutcome<T>>>({});\n\n const nextData: Record<string, T> = {};\n const nextOutcomes: Record<string, BatchFirestoreDocOutcome<T>> = {};\n let anyError: Error | null = null;\n let anyListenerError: Error | null = null;\n let oneShotLoading = false;\n let anyUnresolved = false;\n\n for (let index = 0; index < uniqueIds.length; index += 1) {\n const id = uniqueIds[index];\n const result = results[index];\n let outcome: BatchFirestoreDocOutcome<T> = LOADING_OUTCOME;\n // A one-shot reader of a key a failed shared listener owns sees that listener's `null`\n // placeholder in the cache; the registry's error is what it stands for.\n const listenerError =\n (subscribe ? subscribeErrors[id] : undefined) ??\n listenerErrorOf(queryClient, queryKeyPrefix, id) ??\n undefined;\n if (result?.isError) {\n const error = result.error as Error;\n anyError ??= error;\n outcome = { status: 'failed', error };\n } else if (result) {\n if (result.isLoading) oneShotLoading = true;\n const value = result.data as T | null | undefined;\n if (value === undefined) anyUnresolved = true;\n if (listenerError) {\n anyListenerError ??= listenerError;\n outcome = { status: 'failed', error: listenerError };\n } else if (value === null) {\n outcome = ABSENT_OUTCOME;\n } else if (value !== undefined) {\n nextData[id] = value;\n outcome = { status: 'present', data: value };\n }\n }\n const committed = committedOutcomesRef.current[id];\n nextOutcomes[id] = committed && sameOutcome(committed, outcome) ? committed : outcome;\n }\n\n const data = shallowRecordEqual(committedDataRef.current, nextData)\n ? committedDataRef.current\n : nextData;\n const outcomes = shallowRecordEqual(committedOutcomesRef.current, nextOutcomes)\n ? committedOutcomesRef.current\n : nextOutcomes;\n\n useEffect(() => {\n committedDataRef.current = data;\n committedOutcomesRef.current = outcomes;\n }, [data, outcomes]);\n\n const refetch = useCallback(async () => {\n // Subscribe mode owns its freshness through the shared listeners — refetching would\n // issue reads against disabled queries and race the listener's value.\n if (subscribe || !enabled) return;\n await Promise.all(\n uniqueIds.map((id) =>\n queryClient.refetchQueries({ queryKey: [queryKeyPrefix, id], exact: true }),\n ),\n );\n }, [uniqueIds, subscribe, enabled, queryClient, queryKeyPrefix]);\n\n // In subscribe mode \"loading\" means a subscribed id has no cache entry yet (`null`\n // counts as resolved-missing).\n const isLoading = subscribe ? enabled && anyUnresolved : oneShotLoading;\n\n return {\n data,\n isLoading,\n isError: anyError !== null || anyListenerError !== null,\n error: anyError ?? anyListenerError,\n outcomes,\n refetch,\n };\n}\n"]}
@@ -6,7 +6,7 @@ export { FirestoreProvider, useFirestoreDb } from './firestore/context.js';
6
6
  export type { FirestoreProviderProps } from './firestore/context.js';
7
7
  export { useFirestoreDoc } from './firestore/use-firestore-doc.js';
8
8
  export { useFirestoreCollection } from './firestore/use-firestore-collection.js';
9
- export type { FirestoreSourceState, WithSourceState } from '../firestore/types.js';
9
+ export type { FirestoreSourceState, WithSourceState, BatchFirestoreDocOutcome } from '../firestore/types.js';
10
10
  export { useFirestoreInfinite } from './firestore/use-firestore-infinite.js';
11
11
  export { useFirestorePaginated } from './firestore/use-firestore-paginated.js';
12
12
  export type { UseFirestorePaginatedResult } from './firestore/use-firestore-paginated.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/react/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AACvD,YAAY,EAAE,wBAAwB,EAAE,MAAM,oBAAoB,CAAC;AAGnE,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,YAAY,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAGxD,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAC3E,YAAY,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAC;AAGrE,OAAO,EAAE,eAAe,EAAE,MAAM,kCAAkC,CAAC;AACnE,OAAO,EAAE,sBAAsB,EAAE,MAAM,yCAAyC,CAAC;AACjF,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACnF,OAAO,EAAE,oBAAoB,EAAE,MAAM,uCAAuC,CAAC;AAC7E,OAAO,EAAE,qBAAqB,EAAE,MAAM,wCAAwC,CAAC;AAC/E,YAAY,EAAE,2BAA2B,EAAE,MAAM,wCAAwC,CAAC;AAC1F,OAAO,EAAE,iBAAiB,EAAE,MAAM,oCAAoC,CAAC;AACvE,OAAO,EAAE,wBAAwB,EAAE,MAAM,4CAA4C,CAAC;AACtF,YAAY,EAAE,2BAA2B,EAAE,MAAM,4CAA4C,CAAC;AAG9F,OAAO,EACL,eAAe,EACf,kBAAkB,EAClB,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,wCAAwC,CAAC;AAGhD,OAAO,EACL,qBAAqB,GACtB,MAAM,sCAAsC,CAAC;AAC9C,YAAY,EACV,yBAAyB,EACzB,wBAAwB,EACxB,2BAA2B,GAC5B,MAAM,sCAAsC,CAAC;AAC9C,OAAO,EAAE,sBAAsB,EAAE,MAAM,kCAAkC,CAAC;AAG1E,OAAO,EAAE,kBAAkB,EAAE,MAAM,kCAAkC,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/react/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AACvD,YAAY,EAAE,wBAAwB,EAAE,MAAM,oBAAoB,CAAC;AAGnE,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,YAAY,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAGxD,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAC3E,YAAY,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAC;AAGrE,OAAO,EAAE,eAAe,EAAE,MAAM,kCAAkC,CAAC;AACnE,OAAO,EAAE,sBAAsB,EAAE,MAAM,yCAAyC,CAAC;AACjF,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,wBAAwB,EAAE,MAAM,uBAAuB,CAAC;AAC7G,OAAO,EAAE,oBAAoB,EAAE,MAAM,uCAAuC,CAAC;AAC7E,OAAO,EAAE,qBAAqB,EAAE,MAAM,wCAAwC,CAAC;AAC/E,YAAY,EAAE,2BAA2B,EAAE,MAAM,wCAAwC,CAAC;AAC1F,OAAO,EAAE,iBAAiB,EAAE,MAAM,oCAAoC,CAAC;AACvE,OAAO,EAAE,wBAAwB,EAAE,MAAM,4CAA4C,CAAC;AACtF,YAAY,EAAE,2BAA2B,EAAE,MAAM,4CAA4C,CAAC;AAG9F,OAAO,EACL,eAAe,EACf,kBAAkB,EAClB,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,wCAAwC,CAAC;AAGhD,OAAO,EACL,qBAAqB,GACtB,MAAM,sCAAsC,CAAC;AAC9C,YAAY,EACV,yBAAyB,EACzB,wBAAwB,EACxB,2BAA2B,GAC5B,MAAM,sCAAsC,CAAC;AAC9C,OAAO,EAAE,sBAAsB,EAAE,MAAM,kCAAkC,CAAC;AAG1E,OAAO,EAAE,kBAAkB,EAAE,MAAM,kCAAkC,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/react/index.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,qEAAqE;AACrE,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAGvD,WAAW;AACX,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAG9C,qBAAqB;AACrB,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAG3E,wBAAwB;AACxB,OAAO,EAAE,eAAe,EAAE,MAAM,kCAAkC,CAAC;AACnE,OAAO,EAAE,sBAAsB,EAAE,MAAM,yCAAyC,CAAC;AAEjF,OAAO,EAAE,oBAAoB,EAAE,MAAM,uCAAuC,CAAC;AAC7E,OAAO,EAAE,qBAAqB,EAAE,MAAM,wCAAwC,CAAC;AAE/E,OAAO,EAAE,iBAAiB,EAAE,MAAM,oCAAoC,CAAC;AACvE,OAAO,EAAE,wBAAwB,EAAE,MAAM,4CAA4C,CAAC;AAGtF,2BAA2B;AAC3B,OAAO,EACL,eAAe,EACf,kBAAkB,EAClB,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,wCAAwC,CAAC;AAEhD,uBAAuB;AACvB,OAAO,EACL,qBAAqB,GACtB,MAAM,sCAAsC,CAAC;AAM9C,OAAO,EAAE,sBAAsB,EAAE,MAAM,kCAAkC,CAAC;AAE1E,cAAc;AACd,OAAO,EAAE,kBAAkB,EAAE,MAAM,kCAAkC,CAAC","sourcesContent":["\"use client\";\n\n// Query Client factory (client-only; requires @tanstack/react-query)\nexport { createQueryClient } from '../query-client.js';\nexport type { CreateQueryClientOptions } from '../query-client.js';\n\n// Provider\nexport { QueryProvider } from './provider.js';\nexport type { QueryProviderProps } from './provider.js';\n\n// Firestore Provider\nexport { FirestoreProvider, useFirestoreDb } from './firestore/context.js';\nexport type { FirestoreProviderProps } from './firestore/context.js';\n\n// Firestore Query Hooks\nexport { useFirestoreDoc } from './firestore/use-firestore-doc.js';\nexport { useFirestoreCollection } from './firestore/use-firestore-collection.js';\nexport type { FirestoreSourceState, WithSourceState } from '../firestore/types.js';\nexport { useFirestoreInfinite } from './firestore/use-firestore-infinite.js';\nexport { useFirestorePaginated } from './firestore/use-firestore-paginated.js';\nexport type { UseFirestorePaginatedResult } from './firestore/use-firestore-paginated.js';\nexport { useFirestoreCount } from './firestore/use-firestore-count.js';\nexport { useFirestoreLiveInfinite } from './firestore/use-firestore-live-infinite.js';\nexport type { FirestoreLiveInfiniteResult } from './firestore/use-firestore-live-infinite.js';\n\n// Firestore Mutation Hooks\nexport {\n useFirestoreSet,\n useFirestoreUpdate,\n useFirestoreDelete,\n useFirestoreBatch,\n} from './firestore/use-firestore-mutations.js';\n\n// Batch Firestore Docs\nexport {\n useBatchFirestoreDocs,\n} from './firestore/useBatchFirestoreDocs.js';\nexport type {\n BatchFirestoreDocsOptions,\n BatchFirestoreDocsResult,\n BatchFirestoreDocsTransport,\n} from './firestore/useBatchFirestoreDocs.js';\nexport { ABSENT_RETRY_DELAYS_MS } from './firestore/absence-scheduler.js';\n\n// Search Hook\nexport { useFirestoreSearch } from './search/use-firestore-search.js';\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/react/index.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,qEAAqE;AACrE,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAGvD,WAAW;AACX,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAG9C,qBAAqB;AACrB,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAG3E,wBAAwB;AACxB,OAAO,EAAE,eAAe,EAAE,MAAM,kCAAkC,CAAC;AACnE,OAAO,EAAE,sBAAsB,EAAE,MAAM,yCAAyC,CAAC;AAEjF,OAAO,EAAE,oBAAoB,EAAE,MAAM,uCAAuC,CAAC;AAC7E,OAAO,EAAE,qBAAqB,EAAE,MAAM,wCAAwC,CAAC;AAE/E,OAAO,EAAE,iBAAiB,EAAE,MAAM,oCAAoC,CAAC;AACvE,OAAO,EAAE,wBAAwB,EAAE,MAAM,4CAA4C,CAAC;AAGtF,2BAA2B;AAC3B,OAAO,EACL,eAAe,EACf,kBAAkB,EAClB,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,wCAAwC,CAAC;AAEhD,uBAAuB;AACvB,OAAO,EACL,qBAAqB,GACtB,MAAM,sCAAsC,CAAC;AAM9C,OAAO,EAAE,sBAAsB,EAAE,MAAM,kCAAkC,CAAC;AAE1E,cAAc;AACd,OAAO,EAAE,kBAAkB,EAAE,MAAM,kCAAkC,CAAC","sourcesContent":["\"use client\";\n\n// Query Client factory (client-only; requires @tanstack/react-query)\nexport { createQueryClient } from '../query-client.js';\nexport type { CreateQueryClientOptions } from '../query-client.js';\n\n// Provider\nexport { QueryProvider } from './provider.js';\nexport type { QueryProviderProps } from './provider.js';\n\n// Firestore Provider\nexport { FirestoreProvider, useFirestoreDb } from './firestore/context.js';\nexport type { FirestoreProviderProps } from './firestore/context.js';\n\n// Firestore Query Hooks\nexport { useFirestoreDoc } from './firestore/use-firestore-doc.js';\nexport { useFirestoreCollection } from './firestore/use-firestore-collection.js';\nexport type { FirestoreSourceState, WithSourceState, BatchFirestoreDocOutcome } from '../firestore/types.js';\nexport { useFirestoreInfinite } from './firestore/use-firestore-infinite.js';\nexport { useFirestorePaginated } from './firestore/use-firestore-paginated.js';\nexport type { UseFirestorePaginatedResult } from './firestore/use-firestore-paginated.js';\nexport { useFirestoreCount } from './firestore/use-firestore-count.js';\nexport { useFirestoreLiveInfinite } from './firestore/use-firestore-live-infinite.js';\nexport type { FirestoreLiveInfiniteResult } from './firestore/use-firestore-live-infinite.js';\n\n// Firestore Mutation Hooks\nexport {\n useFirestoreSet,\n useFirestoreUpdate,\n useFirestoreDelete,\n useFirestoreBatch,\n} from './firestore/use-firestore-mutations.js';\n\n// Batch Firestore Docs\nexport {\n useBatchFirestoreDocs,\n} from './firestore/useBatchFirestoreDocs.js';\nexport type {\n BatchFirestoreDocsOptions,\n BatchFirestoreDocsResult,\n BatchFirestoreDocsTransport,\n} from './firestore/useBatchFirestoreDocs.js';\nexport { ABSENT_RETRY_DELAYS_MS } from './firestore/absence-scheduler.js';\n\n// Search Hook\nexport { useFirestoreSearch } from './search/use-firestore-search.js';\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttt-productions/query-core",
3
- "version": "0.14.3",
3
+ "version": "0.14.5",
4
4
  "description": "Shared TanStack Query (React Query) client defaults, query key helpers, and Firestore integration hooks for TTT Productions apps",
5
5
  "repository": {
6
6
  "type": "git",
@@ -63,10 +63,9 @@
63
63
  "@tanstack/react-query": "^5.0.0",
64
64
  "@types/react": "^19.0.0",
65
65
  "@types/react-dom": "^19.0.0",
66
- "firebase": "^11.0.0",
66
+ "firebase": "^12.14.0",
67
67
  "react": "^19.2.0",
68
- "react-dom": "^19.2.0",
69
- "typescript": "^5.8.3"
68
+ "react-dom": "^19.2.0"
70
69
  },
71
70
  "author": "DJ (TTT Productions)",
72
71
  "license": "MIT"