@aglyn/tenant-feature-instance 1.0.0-beta.202 → 1.0.0-beta.204

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.
@@ -16,9 +16,10 @@
16
16
  */ 'use client';
17
17
  import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { compareScored, nameSearchKey, scoreMatch } from "@aglyn/aglyn";
19
- import { collection, documentId, endAt, getDocs, limit, orderBy, query, startAt, where } from "firebase/firestore";
19
+ import { collection, documentId, endAt, limit, orderBy, query, startAt, where } from "firebase/firestore";
20
20
  import { useEffect, useRef, useState } from "react";
21
21
  import { reportFirestoreDenial, reportFirestoreServerRead } from "./firestore-denial-reporter.js";
22
+ import { getDocsBounded } from "./firebase/firestore-bounded-read.js";
22
23
  /**
23
24
  * Collection KEY for the session-health verdict (AGL-2486).
24
25
  *
@@ -151,7 +152,7 @@ function useDebouncedValue(value, ms) {
151
152
  let windowRows = windowRef.current;
152
153
  if (!windowRows) {
153
154
  var _windowSnapshot_metadata;
154
- const windowSnapshot = await getDocs(query(ref, ...scopeFilter, orderBy(documentId()), limit(searchWindow)));
155
+ const { snapshot: windowSnapshot } = await getDocsBounded(query(ref, ...scopeFilter, orderBy(documentId()), limit(searchWindow)));
155
156
  windowRows = {
156
157
  rows: toRows(windowSnapshot),
157
158
  fromCache: (_windowSnapshot_metadata = windowSnapshot.metadata) == null ? void 0 : _windowSnapshot_metadata.fromCache
@@ -176,7 +177,7 @@ function useDebouncedValue(value, ms) {
176
177
  let beyondFromCache;
177
178
  if (windowRows.rows.length >= searchWindow) {
178
179
  var _prefixSnapshot_metadata;
179
- const prefixSnapshot = await getDocs(query(ref, ...scopeFilter, orderBy('nameLower'), startAt(key), endAt(key + '\uf8ff'), limit(searchLimit)));
180
+ const { snapshot: prefixSnapshot } = await getDocsBounded(query(ref, ...scopeFilter, orderBy('nameLower'), startAt(key), endAt(key + '\uf8ff'), limit(searchLimit)));
180
181
  beyondFromCache = (_prefixSnapshot_metadata = prefixSnapshot.metadata) == null ? void 0 : _prefixSnapshot_metadata.fromCache;
181
182
  beyond = toRows(prefixSnapshot);
182
183
  }
@@ -193,7 +194,8 @@ function useDebouncedValue(value, ms) {
193
194
  fromCache: windowRows.fromCache === false ? false : beyondFromCache
194
195
  };
195
196
  };
196
- const read = hasQuery ? searchRead() : getDocs(query(ref, ...scopeFilter, orderBy('updatedAt', 'desc'), limit(idleLimit))).then((snapshot)=>{
197
+ const read = hasQuery ? searchRead() : // marked `fromCache`, instead of holding the menu on its spinner.
198
+ getDocsBounded(query(ref, ...scopeFilter, orderBy('updatedAt', 'desc'), limit(idleLimit))).then(({ snapshot })=>{
197
199
  var _snapshot_metadata;
198
200
  return {
199
201
  rows: toRows(snapshot),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-switcher-collection.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { compareScored, nameSearchKey, scoreMatch } from '@aglyn/aglyn'\nimport {\n collection,\n documentId,\n endAt,\n type DocumentData,\n type Firestore,\n getDocs,\n limit,\n orderBy,\n query,\n startAt,\n where,\n type WhereFilterOp,\n} from 'firebase/firestore'\nimport {\n type DependencyList,\n useEffect,\n useRef,\n useState,\n} from 'react'\nimport {\n reportFirestoreDenial,\n reportFirestoreServerRead,\n} from './firestore-denial-reporter'\n\n/**\n * Collection KEY for the session-health verdict (AGL-2486).\n *\n * The even path segments only — `['users', uid, 'hostMemberships']` becomes\n * `users/hostMemberships`. That is the convention `use-org-hosts` already\n * uses for the same collection, so the two report the SAME key and still\n * count as one collection rather than inflating each other toward the\n * two-collection threshold. It also keeps document ids — a uid among them —\n * out of a module-scope map.\n */\nexport function switcherCollectionKey(path: string[]): string {\n return path.filter((_, index) => index % 2 === 0).join('/')\n}\n\nexport interface UseSwitcherCollectionOptions<T> {\n firestore: Firestore\n /** Collection path segments, e.g. `['hosts', hostId, 'screens']`. */\n path: string[]\n /** Optional single filter, e.g. `['orgId', '==', orgId]`. */\n where?: readonly [string, WhereFilterOp, unknown]\n /** Raw search text from the field; debounced and normalized internally. */\n query: string\n /** Recent-first window size when the query is empty (default 10). */\n idleLimit?: number\n /** Result cap for the name-prefix search (default 20). */\n searchLimit?: number\n /**\n * The field holding the human-readable name, matched client-side.\n *\n * Both switchers store it as `displayName`, which is the default; a caller\n * whose rows name themselves differently passes its own.\n */\n nameField?: string\n /**\n * How many documents the client-side pass reads (default 50).\n *\n * Fetched ONCE per scope and cached for the life of the mount, so typing\n * costs nothing after the first character — see the search-mode note on the\n * hook itself.\n */\n searchWindow?: number\n /** Field written with the doc id (default `$id`). */\n idField?: string\n /** Client post-filter, e.g. drop soft-deleted or email screens. */\n filter?: (item: T) => boolean\n /** Stable scope dependencies (firestore, ids) — like a useEffect dep array. */\n deps: DependencyList\n /** Debounce for the search query in ms (default 200). */\n debounceMs?: number\n /**\n * Hold off entirely, because a value this listen must be SCOPED BY is not\n * resolved yet (AGL-2350).\n *\n * The empty-path-segment hold-off below covers a scope that appears IN the\n * path. A `where` filter is the other kind, and it fails the opposite way:\n * an unresolved id makes `where` `undefined`, which does not error — it\n * silently drops the filter and returns the UNSCOPED collection. For\n * `users/{uid}/hostMemberships` that is every site the person holds in\n * every org, which on an agency running one workspace per client puts two\n * clients' site names in one dropdown.\n *\n * Defaults to `false`, so this is inert for any caller that does not scope\n * by a filter.\n */\n skip?: boolean\n}\n\nexport interface UseSwitcherCollectionResult<T> {\n items: T[]\n /** A fetch is in flight and there are no prior rows to show yet. */\n loading: boolean\n /** The (debounced) query is non-empty — results are name-search matches. */\n hasQuery: boolean\n /**\n * The most recent fetch FAILED (AGL-1066).\n *\n * Keeping the prior rows on a failure is right and stays (see the catch\n * below), but the failure was previously erased along with `loading` — so\n * the first fetch of a scope, which has no prior rows, settled on\n * `{ items: [], loading: false }` and every switcher read that as \"this\n * person holds no sites\". Consumers must render \"couldn't load\", never a\n * zero-state, when this is true.\n */\n error: boolean\n}\n\nfunction useDebouncedValue<V>(value: V, ms: number): V {\n const [debounced, setDebounced] = useState(value)\n useEffect(() => {\n const timer = setTimeout(() => setDebounced(value), ms)\n return () => clearTimeout(timer)\n }, [value, ms])\n return debounced\n}\n\n/**\n * Data source for the org/site/screen switchers that scales past a switcher's\n * naive \"load the whole collection and filter in memory\" (AGL-838). Two modes:\n *\n * - **idle** (empty query): a recent-first window, `orderBy(updatedAt desc)`\n * capped at `idleLimit` — the handful of docs you actually cycle between, not\n * a full read.\n * - **search** (non-empty query): a true Firestore prefix range over the\n * normalized `nameLower` (`orderBy(nameLower) startAt(key)..endAt(key+'\\uf8ff')`),\n * capped at `searchLimit` — finds a match anywhere in the collection without\n * loading it, where the old client-side filter could only match already-\n * loaded rows.\n *\n * Reads are one-shot `getDocs` (a switcher list doesn't need live updates), so\n * there is no per-keystroke listener churn and no flash: prior rows stay\n * visible while the next fetch is in flight, and only a scope change (a\n * dependency in `deps`) clears them — the same hold-the-right-scope rule as\n * `useFirestoreCollection` (AGL-591), without its clear-to-empty-then-refill.\n * Out-of-order responses are dropped by request id.\n */\nexport function useSwitcherCollection<T = DocumentData>(\n options: UseSwitcherCollectionOptions<T>,\n): UseSwitcherCollectionResult<T> {\n const {\n firestore,\n path,\n where: whereClause,\n query: rawQuery,\n idleLimit = 10,\n searchLimit = 20,\n nameField = 'displayName',\n searchWindow = 50,\n idField = '$id',\n filter,\n deps,\n debounceMs = 200,\n skip = false,\n } = options\n\n const debounced = useDebouncedValue(rawQuery.trim(), debounceMs)\n const key = nameSearchKey(debounced)\n const hasQuery = key.length > 0\n\n const [items, setItems] = useState<T[]>([])\n const [loading, setLoading] = useState(true)\n const [error, setError] = useState(false)\n const requestRef = useRef(0)\n /**\n * The client-side search window, cached for the life of this SCOPE.\n *\n * A ref rather than state: filling it must not itself cause a render, and\n * the render that matters is the one `setItems` already causes. Cleared by\n * the scope effect below, which is the same place the rows are cleared —\n * keeping the two together is what stops site A's window being matched\n * against while standing on site B.\n */\n const windowRef = useRef<{ rows: any[]; fromCache?: boolean } | null>(null)\n\n // Clear on a genuine scope change (host A → host B) so the previous scope's\n // rows never bleed into the new one; a query change keeps the prior rows so\n // the list doesn't flash (AGL-591 rule, applied per-scope not per-fetch).\n // eslint-disable-next-line react-hooks/exhaustive-deps\n useEffect(() => {\n setItems([])\n setLoading(true)\n setError(false)\n windowRef.current = null\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, deps)\n\n useEffect(() => {\n const requestId = ++requestRef.current\n // A path segment can be momentarily empty (e.g. the uid before auth\n // resolves); hold rather than build an invalid collection ref. `skip` is\n // the same hold for a scope that lives in `where` rather than the path,\n // where an unresolved id would widen the query instead of breaking it.\n if (skip || path.some((segment) => !segment)) {\n setItems([])\n setLoading(true)\n setError(false)\n // A hold means the scope is not trustworthy yet, so anything already\n // read under it must not survive into the scope that unblocks.\n windowRef.current = null\n return\n }\n setLoading(true)\n const ref = collection(firestore, path[0], ...path.slice(1))\n const scopeFilter = whereClause\n ? [where(whereClause[0], whereClause[1], whereClause[2])]\n : []\n const toRows = (snapshot: { docs: any[] }) =>\n snapshot.docs.map((docSnap) => {\n const value = { ...docSnap.data() } as Record<string, unknown>\n if (idField) value[idField] = docSnap.id\n return value as T\n })\n\n /**\n * SEARCH MODE (AGL-2486) — a client-side window first, the prefix range\n * only when the window cannot have held everything.\n *\n * This used to be the prefix range alone, and that had two defects which\n * look like one from the outside.\n *\n * **It hid documents.** `orderBy('nameLower')` makes Firestore OMIT every\n * document that does not carry the field — and `nameLower` is optional,\n * stamped by three write paths for one resource kind. A document written\n * any other way stayed visible in the idle list and vanished the instant\n * you typed. Measured against the seeded emulator: a screen named \"Home\"\n * listed, and `home` returned nothing. Production happens to be clean\n * today (68/68 live screens and 10/10 membership rows carry it, checked\n * with the Admin SDK), so this is a LATENT defect rather than a firing\n * one — but it is armed for the next write path that forgets, and nothing\n * would fail when it does. Ordering the window by `documentId()`, which no\n * document can be missing, disarms it permanently: correctness stops\n * depending on a field staying in step across write paths.\n *\n * **And it could not match the way people type.** A prefix over the whole\n * stored name means somebody looking for \"Main Layout\" who types `layout`\n * gets nothing. That one IS firing, on every surface, today.\n *\n * The window is read ONCE per scope and cached for the life of the mount,\n * so the common case — a site whose collection fits inside it — costs one\n * read burst and then nothing at all per keystroke, which is CHEAPER than\n * the query it replaces. The prefix range is kept and issued only when the\n * window came back full, because that is the case AGL-838 built it for: a\n * host with hundreds of screens must still find one that is not in the\n * window. Dropping it to simplify would have regressed exactly the\n * property the switcher exists to provide.\n */\n const searchRead = async () => {\n let windowRows = windowRef.current\n if (!windowRows) {\n const windowSnapshot = await getDocs(\n query(ref, ...scopeFilter, orderBy(documentId()), limit(searchWindow)),\n )\n windowRows = {\n rows: toRows(windowSnapshot),\n fromCache: windowSnapshot.metadata?.fromCache,\n }\n windowRef.current = windowRows\n }\n const scored = windowRows.rows\n .map((row: any) => {\n const label = String(row?.[nameField] ?? '')\n const score = scoreMatch({ name: label }, debounced)\n return score === null ? null : { row, score, label }\n })\n .filter(Boolean) as Array<{ row: T; score: number; label: string }>\n\n // Only reach past the window when the window could not have held the\n // whole collection. A partial window is proof there is nothing beyond it.\n let beyond: T[] = []\n let beyondFromCache: boolean | undefined\n if (windowRows.rows.length >= searchWindow) {\n const prefixSnapshot = await getDocs(\n query(\n ref,\n ...scopeFilter,\n orderBy('nameLower'),\n startAt(key),\n endAt(key + '\\uf8ff'),\n limit(searchLimit),\n ),\n )\n beyondFromCache = prefixSnapshot.metadata?.fromCache\n beyond = toRows(prefixSnapshot)\n }\n\n const seen = new Set(scored.map((hit) => (hit.row as any)?.[idField]))\n const merged = [\n ...scored\n .sort((a, b) => compareScored(a, b))\n .map((hit) => hit.row),\n ...beyond.filter((row: any) => !seen.has(row?.[idField])),\n ]\n return {\n rows: merged.slice(0, searchLimit),\n fromCache: windowRows.fromCache === false ? false : beyondFromCache,\n }\n }\n\n const read = hasQuery\n ? searchRead()\n : getDocs(\n query(ref, ...scopeFilter, orderBy('updatedAt', 'desc'), limit(idleLimit)),\n ).then((snapshot) => ({\n rows: toRows(snapshot),\n fromCache: snapshot.metadata?.fromCache,\n }))\n\n read\n .then((result) => {\n if (requestRef.current !== requestId) return // superseded\n const snapshot = { metadata: { fromCache: result.fromCache } }\n const rows = result.rows\n setItems(filter ? rows.filter(filter) : rows)\n setError(false)\n setLoading(false)\n // A SERVER answer is proof the session can read, and it clears the\n // denial evidence outright (AGL-2486). Guarded on `fromCache`\n // because `getDocs` falls back to the cache while offline, and the\n // reporter contract is explicit that a cached snapshot proves\n // nothing.\n //\n // Written as `=== false` rather than `!fromCache` on purpose: the\n // claim being made is \"the server answered\", so absent metadata\n // must report NOTHING rather than be read as a server read. It also\n // keeps this branch from throwing on a snapshot shape it did not\n // expect — a throw here lands in the `catch` below and would show\n // the user a refusal for a fetch that actually succeeded.\n if (snapshot.metadata?.fromCache === false) reportFirestoreServerRead()\n })\n .catch((error: unknown) => {\n // A missing composite index or transient error leaves the prior rows\n // in place rather than blanking the menu; the caller's \"view all\"\n // escape hatch still reaches everything.\n //\n // But SAY SO (AGL-1066). Swallowing the failure into `loading: false`\n // meant the no-prior-rows case — a cold load, or the first fetch\n // after a scope change — was indistinguishable from a genuinely\n // empty collection, and the site switcher printed \"No sites yet.\"\n // at people who hold sites.\n if (requestRef.current !== requestId) return\n setError(true)\n setLoading(false)\n // …and tell the session detector, which this read used to keep to\n // itself (AGL-2486). the production report was a Sites list that\n // \"could not be loaded\" while the console had no idea the session\n // was the reason: the switcher is one of the first reads on the\n // page and it contributed ZERO evidence toward the stale verdict,\n // so the very list that failed could never be what raised the\n // prompt. Only `permission-denied` — a missing index\n // (`failed-precondition`) or a dropped network (`unavailable`) is\n // not a session problem and must never be counted as one.\n if ((error as { code?: string })?.code === 'permission-denied') {\n reportFirestoreDenial(switcherCollectionKey(path))\n }\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n // `skip` is listed even though it is derived from a value already in\n // `deps` for every caller today — a future caller whose skip condition is\n // independent of its scope would otherwise stay held after unblocking.\n }, [...deps, key, hasQuery, idleLimit, searchLimit, skip])\n\n return { items, loading, hasQuery, error }\n}\n\nexport default useSwitcherCollection\n"],"names":["compareScored","nameSearchKey","scoreMatch","collection","documentId","endAt","getDocs","limit","orderBy","query","startAt","where","useEffect","useRef","useState","reportFirestoreDenial","reportFirestoreServerRead","switcherCollectionKey","path","filter","_","index","join","useDebouncedValue","value","ms","debounced","setDebounced","timer","setTimeout","clearTimeout","useSwitcherCollection","options","firestore","whereClause","rawQuery","idleLimit","searchLimit","nameField","searchWindow","idField","deps","debounceMs","skip","trim","key","hasQuery","length","items","setItems","loading","setLoading","error","setError","requestRef","windowRef","current","requestId","some","segment","ref","slice","scopeFilter","toRows","snapshot","docs","map","docSnap","data","id","searchRead","windowRows","windowSnapshot","rows","fromCache","metadata","scored","row","label","String","score","name","Boolean","beyond","beyondFromCache","prefixSnapshot","seen","Set","hit","merged","sort","a","b","has","read","then","result","catch","code"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-switcher-collection.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { compareScored, nameSearchKey, scoreMatch } from '@aglyn/aglyn'\nimport {\n collection,\n documentId,\n endAt,\n type DocumentData,\n type Firestore,\n limit,\n orderBy,\n query,\n startAt,\n where,\n type WhereFilterOp,\n} from 'firebase/firestore'\nimport {\n type DependencyList,\n useEffect,\n useRef,\n useState,\n} from 'react'\nimport {\n reportFirestoreDenial,\n reportFirestoreServerRead,\n} from './firestore-denial-reporter'\nimport { getDocsBounded } from './firebase/firestore-bounded-read'\n\n/**\n * Collection KEY for the session-health verdict (AGL-2486).\n *\n * The even path segments only — `['users', uid, 'hostMemberships']` becomes\n * `users/hostMemberships`. That is the convention `use-org-hosts` already\n * uses for the same collection, so the two report the SAME key and still\n * count as one collection rather than inflating each other toward the\n * two-collection threshold. It also keeps document ids — a uid among them —\n * out of a module-scope map.\n */\nexport function switcherCollectionKey(path: string[]): string {\n return path.filter((_, index) => index % 2 === 0).join('/')\n}\n\nexport interface UseSwitcherCollectionOptions<T> {\n firestore: Firestore\n /** Collection path segments, e.g. `['hosts', hostId, 'screens']`. */\n path: string[]\n /** Optional single filter, e.g. `['orgId', '==', orgId]`. */\n where?: readonly [string, WhereFilterOp, unknown]\n /** Raw search text from the field; debounced and normalized internally. */\n query: string\n /** Recent-first window size when the query is empty (default 10). */\n idleLimit?: number\n /** Result cap for the name-prefix search (default 20). */\n searchLimit?: number\n /**\n * The field holding the human-readable name, matched client-side.\n *\n * Both switchers store it as `displayName`, which is the default; a caller\n * whose rows name themselves differently passes its own.\n */\n nameField?: string\n /**\n * How many documents the client-side pass reads (default 50).\n *\n * Fetched ONCE per scope and cached for the life of the mount, so typing\n * costs nothing after the first character — see the search-mode note on the\n * hook itself.\n */\n searchWindow?: number\n /** Field written with the doc id (default `$id`). */\n idField?: string\n /** Client post-filter, e.g. drop soft-deleted or email screens. */\n filter?: (item: T) => boolean\n /** Stable scope dependencies (firestore, ids) — like a useEffect dep array. */\n deps: DependencyList\n /** Debounce for the search query in ms (default 200). */\n debounceMs?: number\n /**\n * Hold off entirely, because a value this listen must be SCOPED BY is not\n * resolved yet (AGL-2350).\n *\n * The empty-path-segment hold-off below covers a scope that appears IN the\n * path. A `where` filter is the other kind, and it fails the opposite way:\n * an unresolved id makes `where` `undefined`, which does not error — it\n * silently drops the filter and returns the UNSCOPED collection. For\n * `users/{uid}/hostMemberships` that is every site the person holds in\n * every org, which on an agency running one workspace per client puts two\n * clients' site names in one dropdown.\n *\n * Defaults to `false`, so this is inert for any caller that does not scope\n * by a filter.\n */\n skip?: boolean\n}\n\nexport interface UseSwitcherCollectionResult<T> {\n items: T[]\n /** A fetch is in flight and there are no prior rows to show yet. */\n loading: boolean\n /** The (debounced) query is non-empty — results are name-search matches. */\n hasQuery: boolean\n /**\n * The most recent fetch FAILED (AGL-1066).\n *\n * Keeping the prior rows on a failure is right and stays (see the catch\n * below), but the failure was previously erased along with `loading` — so\n * the first fetch of a scope, which has no prior rows, settled on\n * `{ items: [], loading: false }` and every switcher read that as \"this\n * person holds no sites\". Consumers must render \"couldn't load\", never a\n * zero-state, when this is true.\n */\n error: boolean\n}\n\nfunction useDebouncedValue<V>(value: V, ms: number): V {\n const [debounced, setDebounced] = useState(value)\n useEffect(() => {\n const timer = setTimeout(() => setDebounced(value), ms)\n return () => clearTimeout(timer)\n }, [value, ms])\n return debounced\n}\n\n/**\n * Data source for the org/site/screen switchers that scales past a switcher's\n * naive \"load the whole collection and filter in memory\" (AGL-838). Two modes:\n *\n * - **idle** (empty query): a recent-first window, `orderBy(updatedAt desc)`\n * capped at `idleLimit` — the handful of docs you actually cycle between, not\n * a full read.\n * - **search** (non-empty query): a true Firestore prefix range over the\n * normalized `nameLower` (`orderBy(nameLower) startAt(key)..endAt(key+'\\uf8ff')`),\n * capped at `searchLimit` — finds a match anywhere in the collection without\n * loading it, where the old client-side filter could only match already-\n * loaded rows.\n *\n * Reads are one-shot `getDocs` (a switcher list doesn't need live updates), so\n * there is no per-keystroke listener churn and no flash: prior rows stay\n * visible while the next fetch is in flight, and only a scope change (a\n * dependency in `deps`) clears them — the same hold-the-right-scope rule as\n * `useFirestoreCollection` (AGL-591), without its clear-to-empty-then-refill.\n * Out-of-order responses are dropped by request id.\n */\nexport function useSwitcherCollection<T = DocumentData>(\n options: UseSwitcherCollectionOptions<T>,\n): UseSwitcherCollectionResult<T> {\n const {\n firestore,\n path,\n where: whereClause,\n query: rawQuery,\n idleLimit = 10,\n searchLimit = 20,\n nameField = 'displayName',\n searchWindow = 50,\n idField = '$id',\n filter,\n deps,\n debounceMs = 200,\n skip = false,\n } = options\n\n const debounced = useDebouncedValue(rawQuery.trim(), debounceMs)\n const key = nameSearchKey(debounced)\n const hasQuery = key.length > 0\n\n const [items, setItems] = useState<T[]>([])\n const [loading, setLoading] = useState(true)\n const [error, setError] = useState(false)\n const requestRef = useRef(0)\n /**\n * The client-side search window, cached for the life of this SCOPE.\n *\n * A ref rather than state: filling it must not itself cause a render, and\n * the render that matters is the one `setItems` already causes. Cleared by\n * the scope effect below, which is the same place the rows are cleared —\n * keeping the two together is what stops site A's window being matched\n * against while standing on site B.\n */\n const windowRef = useRef<{ rows: any[]; fromCache?: boolean } | null>(null)\n\n // Clear on a genuine scope change (host A → host B) so the previous scope's\n // rows never bleed into the new one; a query change keeps the prior rows so\n // the list doesn't flash (AGL-591 rule, applied per-scope not per-fetch).\n // eslint-disable-next-line react-hooks/exhaustive-deps\n useEffect(() => {\n setItems([])\n setLoading(true)\n setError(false)\n windowRef.current = null\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, deps)\n\n useEffect(() => {\n const requestId = ++requestRef.current\n // A path segment can be momentarily empty (e.g. the uid before auth\n // resolves); hold rather than build an invalid collection ref. `skip` is\n // the same hold for a scope that lives in `where` rather than the path,\n // where an unresolved id would widen the query instead of breaking it.\n if (skip || path.some((segment) => !segment)) {\n setItems([])\n setLoading(true)\n setError(false)\n // A hold means the scope is not trustworthy yet, so anything already\n // read under it must not survive into the scope that unblocks.\n windowRef.current = null\n return\n }\n setLoading(true)\n const ref = collection(firestore, path[0], ...path.slice(1))\n const scopeFilter = whereClause\n ? [where(whereClause[0], whereClause[1], whereClause[2])]\n : []\n const toRows = (snapshot: { docs: any[] }) =>\n snapshot.docs.map((docSnap) => {\n const value = { ...docSnap.data() } as Record<string, unknown>\n if (idField) value[idField] = docSnap.id\n return value as T\n })\n\n /**\n * SEARCH MODE (AGL-2486) — a client-side window first, the prefix range\n * only when the window cannot have held everything.\n *\n * This used to be the prefix range alone, and that had two defects which\n * look like one from the outside.\n *\n * **It hid documents.** `orderBy('nameLower')` makes Firestore OMIT every\n * document that does not carry the field — and `nameLower` is optional,\n * stamped by three write paths for one resource kind. A document written\n * any other way stayed visible in the idle list and vanished the instant\n * you typed. Measured against the seeded emulator: a screen named \"Home\"\n * listed, and `home` returned nothing. Production happens to be clean\n * today (68/68 live screens and 10/10 membership rows carry it, checked\n * with the Admin SDK), so this is a LATENT defect rather than a firing\n * one — but it is armed for the next write path that forgets, and nothing\n * would fail when it does. Ordering the window by `documentId()`, which no\n * document can be missing, disarms it permanently: correctness stops\n * depending on a field staying in step across write paths.\n *\n * **And it could not match the way people type.** A prefix over the whole\n * stored name means somebody looking for \"Main Layout\" who types `layout`\n * gets nothing. That one IS firing, on every surface, today.\n *\n * The window is read ONCE per scope and cached for the life of the mount,\n * so the common case — a site whose collection fits inside it — costs one\n * read burst and then nothing at all per keystroke, which is CHEAPER than\n * the query it replaces. The prefix range is kept and issued only when the\n * window came back full, because that is the case AGL-838 built it for: a\n * host with hundreds of screens must still find one that is not in the\n * window. Dropping it to simplify would have regressed exactly the\n * property the switcher exists to provide.\n */\n const searchRead = async () => {\n let windowRows = windowRef.current\n if (!windowRows) {\n const { snapshot: windowSnapshot } = await getDocsBounded(\n query(ref, ...scopeFilter, orderBy(documentId()), limit(searchWindow)),\n )\n windowRows = {\n rows: toRows(windowSnapshot),\n fromCache: windowSnapshot.metadata?.fromCache,\n }\n windowRef.current = windowRows\n }\n const scored = windowRows.rows\n .map((row: any) => {\n const label = String(row?.[nameField] ?? '')\n const score = scoreMatch({ name: label }, debounced)\n return score === null ? null : { row, score, label }\n })\n .filter(Boolean) as Array<{ row: T; score: number; label: string }>\n\n // Only reach past the window when the window could not have held the\n // whole collection. A partial window is proof there is nothing beyond it.\n let beyond: T[] = []\n let beyondFromCache: boolean | undefined\n if (windowRows.rows.length >= searchWindow) {\n const { snapshot: prefixSnapshot } = await getDocsBounded(\n query(\n ref,\n ...scopeFilter,\n orderBy('nameLower'),\n startAt(key),\n endAt(key + '\\uf8ff'),\n limit(searchLimit),\n ),\n )\n beyondFromCache = prefixSnapshot.metadata?.fromCache\n beyond = toRows(prefixSnapshot)\n }\n\n const seen = new Set(scored.map((hit) => (hit.row as any)?.[idField]))\n const merged = [\n ...scored\n .sort((a, b) => compareScored(a, b))\n .map((hit) => hit.row),\n ...beyond.filter((row: any) => !seen.has(row?.[idField])),\n ]\n return {\n rows: merged.slice(0, searchLimit),\n fromCache: windowRows.fromCache === false ? false : beyondFromCache,\n }\n }\n\n const read = hasQuery\n ? searchRead()\n : // Bounded (AGL-3373): a stalled client answers from the cache,\n // marked `fromCache`, instead of holding the menu on its spinner.\n getDocsBounded(\n query(ref, ...scopeFilter, orderBy('updatedAt', 'desc'), limit(idleLimit)),\n ).then(({ snapshot }) => ({\n rows: toRows(snapshot),\n fromCache: snapshot.metadata?.fromCache,\n }))\n\n read\n .then((result) => {\n if (requestRef.current !== requestId) return // superseded\n const snapshot = { metadata: { fromCache: result.fromCache } }\n const rows = result.rows\n setItems(filter ? rows.filter(filter) : rows)\n setError(false)\n setLoading(false)\n // A SERVER answer is proof the session can read, and it clears the\n // denial evidence outright (AGL-2486). Guarded on `fromCache`\n // because `getDocs` falls back to the cache while offline, and the\n // reporter contract is explicit that a cached snapshot proves\n // nothing.\n //\n // Written as `=== false` rather than `!fromCache` on purpose: the\n // claim being made is \"the server answered\", so absent metadata\n // must report NOTHING rather than be read as a server read. It also\n // keeps this branch from throwing on a snapshot shape it did not\n // expect — a throw here lands in the `catch` below and would show\n // the user a refusal for a fetch that actually succeeded.\n if (snapshot.metadata?.fromCache === false) reportFirestoreServerRead()\n })\n .catch((error: unknown) => {\n // A missing composite index or transient error leaves the prior rows\n // in place rather than blanking the menu; the caller's \"view all\"\n // escape hatch still reaches everything.\n //\n // But SAY SO (AGL-1066). Swallowing the failure into `loading: false`\n // meant the no-prior-rows case — a cold load, or the first fetch\n // after a scope change — was indistinguishable from a genuinely\n // empty collection, and the site switcher printed \"No sites yet.\"\n // at people who hold sites.\n if (requestRef.current !== requestId) return\n setError(true)\n setLoading(false)\n // …and tell the session detector, which this read used to keep to\n // itself (AGL-2486). the production report was a Sites list that\n // \"could not be loaded\" while the console had no idea the session\n // was the reason: the switcher is one of the first reads on the\n // page and it contributed ZERO evidence toward the stale verdict,\n // so the very list that failed could never be what raised the\n // prompt. Only `permission-denied` — a missing index\n // (`failed-precondition`) or a dropped network (`unavailable`) is\n // not a session problem and must never be counted as one.\n if ((error as { code?: string })?.code === 'permission-denied') {\n reportFirestoreDenial(switcherCollectionKey(path))\n }\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n // `skip` is listed even though it is derived from a value already in\n // `deps` for every caller today — a future caller whose skip condition is\n // independent of its scope would otherwise stay held after unblocking.\n }, [...deps, key, hasQuery, idleLimit, searchLimit, skip])\n\n return { items, loading, hasQuery, error }\n}\n\nexport default useSwitcherCollection\n"],"names":["compareScored","nameSearchKey","scoreMatch","collection","documentId","endAt","limit","orderBy","query","startAt","where","useEffect","useRef","useState","reportFirestoreDenial","reportFirestoreServerRead","getDocsBounded","switcherCollectionKey","path","filter","_","index","join","useDebouncedValue","value","ms","debounced","setDebounced","timer","setTimeout","clearTimeout","useSwitcherCollection","options","firestore","whereClause","rawQuery","idleLimit","searchLimit","nameField","searchWindow","idField","deps","debounceMs","skip","trim","key","hasQuery","length","items","setItems","loading","setLoading","error","setError","requestRef","windowRef","current","requestId","some","segment","ref","slice","scopeFilter","toRows","snapshot","docs","map","docSnap","data","id","searchRead","windowRows","windowSnapshot","rows","fromCache","metadata","scored","row","label","String","score","name","Boolean","beyond","beyondFromCache","prefixSnapshot","seen","Set","hit","merged","sort","a","b","has","read","then","result","catch","code"],"mappings":"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"}
@@ -21,6 +21,8 @@ export * from './constants/firebase-emulator-hosts';
21
21
  export * from './hooks/firebase/auth-persistence';
22
22
  export * from './hooks/firebase/firebase-services';
23
23
  export * from './hooks/firebase/firestore-cache';
24
+ export * from './hooks/firebase/firestore-stall-recovery';
25
+ export * from './hooks/firebase/firestore-bounded-read';
24
26
  export * from './hooks/helpers/use-doc';
25
27
  export * from './hooks/helpers/use-modify-doc-callback';
26
28
  export * from './hooks/helpers/besigner-nodes-converter';
package/src/lib/tenant.js CHANGED
@@ -20,6 +20,8 @@ export * from "./constants/firebase-emulator-hosts.js";
20
20
  export * from "./hooks/firebase/auth-persistence.js";
21
21
  export * from "./hooks/firebase/firebase-services.js";
22
22
  export * from "./hooks/firebase/firestore-cache.js";
23
+ export * from "./hooks/firebase/firestore-stall-recovery.js";
24
+ export * from "./hooks/firebase/firestore-bounded-read.js";
23
25
  export * from "./hooks/helpers/use-doc.js";
24
26
  export * from "./hooks/helpers/use-modify-doc-callback.js";
25
27
  export * from "./hooks/helpers/besigner-nodes-converter.js";
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/tenant/feature/instance/src/lib/tenant.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2022 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nexport * from './constants/mock-data'\nexport * from './constants/site-paths'\nexport * from './constants/firebase-config'\nexport * from './constants/firebase-emulator-hosts'\n\nexport * from './hooks/firebase/auth-persistence'\nexport * from './hooks/firebase/firebase-services'\nexport * from './hooks/firebase/firestore-cache'\nexport * from './hooks/helpers/use-doc'\nexport * from './hooks/helpers/use-modify-doc-callback'\nexport * from './hooks/helpers/besigner-nodes-converter'\nexport * from './hooks/helpers/guarded-nodes-save'\nexport * from './hooks/helpers/guarded-seed-write'\n\nexport * from './hooks/firestore-denial-reporter'\nexport * from './hooks/use-firestore-collection'\nexport * from './hooks/use-paged-collection'\nexport * from './hooks/use-sorted-paged-collection'\nexport * from './hooks/sorted-collection-window'\nexport * from './hooks/host-collection-queries'\nexport * from './hooks/list-filter-constraints'\nexport * from './hooks/use-firestore-doc'\nexport * from './hooks/use-switcher-collection'\nexport * from './hooks/use-host-activity-logger'\nexport * from './hooks/use-live-artifact-count'\nexport * from './hooks/use-component'\nexport * from './hooks/use-component-version'\nexport * from './hooks/use-form-version'\nexport * from './hooks/use-host-template'\nexport * from './hooks/use-console-host-route'\nexport * from './hooks/use-host-org-id'\nexport * from './hooks/use-host-campaigns'\nexport * from './hooks/use-org-member-options'\nexport * from './hooks/use-org-plan'\nexport * from './hooks/use-scope-tokens'\nexport * from './hooks/use-host-resource-api'\nexport * from './hooks/use-host-version-api'\nexport * from './hooks/use-duplicate-resource-api'\nexport * from './hooks/use-plugin-api-post'\nexport * from './components/duplicate-resource-dialog'\nexport * from './hooks/use-plugin-config'\nexport * from './hooks/use-host'\nexport * from './hooks/use-layout'\nexport * from './hooks/use-layout-version'\nexport * from './hooks/use-screen'\nexport * from './hooks/use-screen-version'\nexport * from './hooks/use-user-name'\nexport * from './hooks/use-user-photo'\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,cAAc,2BAAuB;AACrC,cAAc,4BAAwB;AACtC,cAAc,iCAA6B;AAC3C,cAAc,yCAAqC;AAEnD,cAAc,uCAAmC;AACjD,cAAc,wCAAoC;AAClD,cAAc,sCAAkC;AAChD,cAAc,6BAAyB;AACvC,cAAc,6CAAyC;AACvD,cAAc,8CAA0C;AACxD,cAAc,wCAAoC;AAClD,cAAc,wCAAoC;AAElD,cAAc,uCAAmC;AACjD,cAAc,sCAAkC;AAChD,cAAc,kCAA8B;AAC5C,cAAc,yCAAqC;AACnD,cAAc,sCAAkC;AAChD,cAAc,qCAAiC;AAC/C,cAAc,qCAAiC;AAC/C,cAAc,+BAA2B;AACzC,cAAc,qCAAiC;AAC/C,cAAc,sCAAkC;AAChD,cAAc,qCAAiC;AAC/C,cAAc,2BAAuB;AACrC,cAAc,mCAA+B;AAC7C,cAAc,8BAA0B;AACxC,cAAc,+BAA2B;AACzC,cAAc,oCAAgC;AAC9C,cAAc,6BAAyB;AACvC,cAAc,gCAA4B;AAC1C,cAAc,oCAAgC;AAC9C,cAAc,0BAAsB;AACpC,cAAc,8BAA0B;AACxC,cAAc,mCAA+B;AAC7C,cAAc,kCAA8B;AAC5C,cAAc,wCAAoC;AAClD,cAAc,iCAA6B;AAC3C,cAAc,4CAAwC;AACtD,cAAc,+BAA2B;AACzC,cAAc,sBAAkB;AAChC,cAAc,wBAAoB;AAClC,cAAc,gCAA4B;AAC1C,cAAc,wBAAoB;AAClC,cAAc,gCAA4B;AAC1C,cAAc,2BAAuB;AACrC,cAAc,4BAAwB"}
1
+ {"version":3,"sources":["../../../../../../../libs/tenant/feature/instance/src/lib/tenant.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2022 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nexport * from './constants/mock-data'\nexport * from './constants/site-paths'\nexport * from './constants/firebase-config'\nexport * from './constants/firebase-emulator-hosts'\n\nexport * from './hooks/firebase/auth-persistence'\nexport * from './hooks/firebase/firebase-services'\nexport * from './hooks/firebase/firestore-cache'\nexport * from './hooks/firebase/firestore-stall-recovery'\nexport * from './hooks/firebase/firestore-bounded-read'\nexport * from './hooks/helpers/use-doc'\nexport * from './hooks/helpers/use-modify-doc-callback'\nexport * from './hooks/helpers/besigner-nodes-converter'\nexport * from './hooks/helpers/guarded-nodes-save'\nexport * from './hooks/helpers/guarded-seed-write'\n\nexport * from './hooks/firestore-denial-reporter'\nexport * from './hooks/use-firestore-collection'\nexport * from './hooks/use-paged-collection'\nexport * from './hooks/use-sorted-paged-collection'\nexport * from './hooks/sorted-collection-window'\nexport * from './hooks/host-collection-queries'\nexport * from './hooks/list-filter-constraints'\nexport * from './hooks/use-firestore-doc'\nexport * from './hooks/use-switcher-collection'\nexport * from './hooks/use-host-activity-logger'\nexport * from './hooks/use-live-artifact-count'\nexport * from './hooks/use-component'\nexport * from './hooks/use-component-version'\nexport * from './hooks/use-form-version'\nexport * from './hooks/use-host-template'\nexport * from './hooks/use-console-host-route'\nexport * from './hooks/use-host-org-id'\nexport * from './hooks/use-host-campaigns'\nexport * from './hooks/use-org-member-options'\nexport * from './hooks/use-org-plan'\nexport * from './hooks/use-scope-tokens'\nexport * from './hooks/use-host-resource-api'\nexport * from './hooks/use-host-version-api'\nexport * from './hooks/use-duplicate-resource-api'\nexport * from './hooks/use-plugin-api-post'\nexport * from './components/duplicate-resource-dialog'\nexport * from './hooks/use-plugin-config'\nexport * from './hooks/use-host'\nexport * from './hooks/use-layout'\nexport * from './hooks/use-layout-version'\nexport * from './hooks/use-screen'\nexport * from './hooks/use-screen-version'\nexport * from './hooks/use-user-name'\nexport * from './hooks/use-user-photo'\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,cAAc,2BAAuB;AACrC,cAAc,4BAAwB;AACtC,cAAc,iCAA6B;AAC3C,cAAc,yCAAqC;AAEnD,cAAc,uCAAmC;AACjD,cAAc,wCAAoC;AAClD,cAAc,sCAAkC;AAChD,cAAc,+CAA2C;AACzD,cAAc,6CAAyC;AACvD,cAAc,6BAAyB;AACvC,cAAc,6CAAyC;AACvD,cAAc,8CAA0C;AACxD,cAAc,wCAAoC;AAClD,cAAc,wCAAoC;AAElD,cAAc,uCAAmC;AACjD,cAAc,sCAAkC;AAChD,cAAc,kCAA8B;AAC5C,cAAc,yCAAqC;AACnD,cAAc,sCAAkC;AAChD,cAAc,qCAAiC;AAC/C,cAAc,qCAAiC;AAC/C,cAAc,+BAA2B;AACzC,cAAc,qCAAiC;AAC/C,cAAc,sCAAkC;AAChD,cAAc,qCAAiC;AAC/C,cAAc,2BAAuB;AACrC,cAAc,mCAA+B;AAC7C,cAAc,8BAA0B;AACxC,cAAc,+BAA2B;AACzC,cAAc,oCAAgC;AAC9C,cAAc,6BAAyB;AACvC,cAAc,gCAA4B;AAC1C,cAAc,oCAAgC;AAC9C,cAAc,0BAAsB;AACpC,cAAc,8BAA0B;AACxC,cAAc,mCAA+B;AAC7C,cAAc,kCAA8B;AAC5C,cAAc,wCAAoC;AAClD,cAAc,iCAA6B;AAC3C,cAAc,4CAAwC;AACtD,cAAc,+BAA2B;AACzC,cAAc,sBAAkB;AAChC,cAAc,wBAAoB;AAClC,cAAc,gCAA4B;AAC1C,cAAc,wBAAoB;AAClC,cAAc,gCAA4B;AAC1C,cAAc,2BAAuB;AACrC,cAAc,4BAAwB"}