@aglyn/tenant-feature-instance 1.0.0-beta.236 → 1.0.0-beta.238
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/src/lib/hooks/firebase/firebase-services.js +9 -1
- package/src/lib/hooks/firebase/firebase-services.js.map +1 -1
- package/src/lib/hooks/firebase/firestore-tab-handoff.d.ts +217 -0
- package/src/lib/hooks/firebase/firestore-tab-handoff.js +433 -0
- package/src/lib/hooks/firebase/firestore-tab-handoff.js.map +1 -0
- package/src/lib/hooks/use-host-activity-logger.js +6 -0
- package/src/lib/hooks/use-host-activity-logger.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/tenant-feature-instance",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.238",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,12 +25,12 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.238",
|
|
29
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.238",
|
|
30
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.238",
|
|
31
|
+
"@aglyn/shared-util-email": "1.0.0-beta.238",
|
|
32
|
+
"@aglyn/shared-util-http": "1.0.0-beta.238",
|
|
33
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.238",
|
|
34
34
|
"@swc/helpers": "0.5.23",
|
|
35
35
|
"lodash-es": "^4.18.1"
|
|
36
36
|
},
|
|
@@ -33,6 +33,7 @@ import { createAuthInstance } from "./auth-persistence.js";
|
|
|
33
33
|
import { firestoreCacheClassFor, localCacheFor, pruneSharedClientStateFor } from "./firestore-cache.js";
|
|
34
34
|
import { markMultiTabFirestore } from "./firestore-multitab-wedge.js";
|
|
35
35
|
import { firestorePersistencePrefix } from "./firestore-shared-client-state.js";
|
|
36
|
+
import { startFirestoreTabHandoff } from "./firestore-tab-handoff.js";
|
|
36
37
|
const FirebaseServicesContext = /*#__PURE__*/ createContext(undefined);
|
|
37
38
|
// Module-scope guards so HMR / remounts don't re-run emulator connection
|
|
38
39
|
// (which throws if called twice) or double-initialize Firestore's
|
|
@@ -241,7 +242,14 @@ let analyticsConsentGate = null;
|
|
|
241
242
|
// Only the durable multi-tab cache can be locked by another tab,
|
|
242
243
|
// so only it gets the lease check after a stalled recovery.
|
|
243
244
|
if (firestoreCacheClass === 'durable' && ((_app_options = app.options) == null ? void 0 : _app_options.projectId)) {
|
|
244
|
-
|
|
245
|
+
const prefix = firestorePersistencePrefix(app.name, app.options.projectId);
|
|
246
|
+
markMultiTabFirestore(getFirestore(app), `${prefix}main`);
|
|
247
|
+
// A visible tab opened while a background tab holds the shared
|
|
248
|
+
// cache's lease takes the lease at boot instead of routing every
|
|
249
|
+
// query through the background tab until its lease lapses
|
|
250
|
+
// (AGL-3660). In initializeFirestore's task: its first step
|
|
251
|
+
// snapshots the other tabs before this tab's client can start.
|
|
252
|
+
void startFirestoreTabHandoff(getFirestore(app), prefix);
|
|
245
253
|
}
|
|
246
254
|
}
|
|
247
255
|
} catch (unused) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/firebase/firebase-services.tsx"],"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 {\n type FirebaseApp,\n type FirebaseOptions,\n getApps,\n initializeApp,\n} from 'firebase/app'\nimport {\n type Analytics,\n getAnalytics as getAnalyticsInstance,\n initializeAnalytics as initializeAnalyticsInstance,\n} from 'firebase/analytics'\nimport { initializeAppCheck, ReCaptchaV3Provider } from 'firebase/app-check'\nimport { analyticsMayEmit } from '@aglyn/aglyn/app-utils/analytics-environment'\nimport { pushPlatformConsentDefault } from '@aglyn/aglyn/app-utils/platform-consent-default'\nimport { VISITOR_CONSENT_CHANGED_EVENT } from '@aglyn/aglyn/app-utils/visitor-consent'\nimport {\n type Auth,\n connectAuthEmulator,\n onIdTokenChanged,\n type User,\n} from 'firebase/auth'\nimport {\n type Firestore,\n connectFirestoreEmulator,\n getFirestore,\n initializeFirestore,\n} from 'firebase/firestore'\nimport {\n type RemoteConfig,\n getRemoteConfig as getRemoteConfigInstance,\n} from 'firebase/remote-config'\nimport {\n createContext,\n useContext,\n useEffect,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react'\nimport {\n FIREBASE_AUTH_EMULATOR_ENABLED,\n FIREBASE_FIRESTORE_EMULATOR_ENABLED,\n} from '@aglyn/shared-data-enums'\nimport {\n APP_CHECK_KEY_MISSING_MESSAGE,\n appCheckSiteKey,\n} from '../../constants/firebase-config'\nimport {\n authEmulatorUrl,\n firestoreEmulatorHost,\n} from '../../constants/firebase-emulator-hosts'\nimport {\n type AuthPersistenceClass,\n createAuthInstance,\n} from './auth-persistence'\nimport {\n firestoreCacheClassFor,\n localCacheFor,\n pruneSharedClientStateFor,\n} from './firestore-cache'\nimport { markMultiTabFirestore } from './firestore-multitab-wedge'\nimport { firestorePersistencePrefix } from './firestore-shared-client-state'\n\n/**\n * Drop-in replacement for reactfire's `ObservableStatus<T>` — reactfire is\n * unmaintained against firebase majors past v9, but its shape is preserved\n * here so `helpers/use-doc.ts`'s already-battle-tested onSnapshot+retry\n * implementation (AGL-216/223) didn't need to change.\n */\nexport interface ObservableStatus<T> {\n status: 'loading' | 'error' | 'success'\n hasEmitted: boolean\n isComplete: boolean\n data: T\n error: Error | undefined\n firstValuePromise: Promise<void>\n /**\n * The emitted `data` includes local writes the server has NOT\n * acknowledged (`snapshot.metadata.hasPendingWrites`).\n *\n * Carried rather than dropped because a caller cannot otherwise tell \"the\n * store holds this\" from \"my browser holds this\". `persistentLocalCache`\n * replays a queued write into the very first snapshot after a reload, so a\n * caller that treats every snapshot as authoritative will adopt its own\n * unacknowledged edit as the saved state — which is how the besigner ended\n * up unable to save real changes (AGL-1262).\n */\n hasPendingWrites: boolean\n /**\n * The emitted `data` came from the local cache and the server has NOT\n * confirmed it (`snapshot.metadata.fromCache`).\n *\n * Carried because it is the only per-listener ground truth about\n * freshness the SDK offers, and the console needs it (AGL-1066). Under\n * `persistentLocalCache` a listener whose server listen is being REFUSED\n * still emits cached snapshots and still reports `status: 'success'` —\n * the retry budget is reset by each cached emission and never spends, so\n * `status` can never reveal the fault (see\n * `use-firestore-collection-cached-retry.spec.ts`).\n *\n * Prefer this over `session-health`'s `staleSession` for anything that\n * asks \"is what I am looking at live?\". `staleSession` is a heuristic\n * needing two denied collections inside a window, and it is unreachable\n * on listener-only pages; this is a fact about THIS listener, costs\n * nothing, and clears itself the instant a server snapshot lands.\n */\n fromCache: boolean\n /**\n * The server has REFUSED this listen for longer than the retry budget, and\n * no server snapshot has arrived since (AGL-1066).\n *\n * `fromCache` says \"unconfirmed\", which is also true offline and true for\n * the first snapshot of a perfectly healthy load. This says \"refused\" —\n * only `permission-denied` counts it, and it takes a whole budget's worth\n * in a row, so it cannot fire offline and cannot fire on the AGL-216/217\n * post-sign-in token race.\n *\n * It is what `status` would say if the retry budget were spendable. It is\n * carried separately because correcting `status` in place would blank\n * every surface that renders cached data through an `error` branch — see\n * the note on `attempt = 0` in `use-firestore-collection`.\n */\n serverDenied: boolean\n}\n\n/** Replaces reactfire's `ReactFireOptions<T>` — drops the `suspense` field, which was never read. */\nexport interface FirestoreDocOptions<T> {\n idField?: string\n initialData?: T\n}\n\n/**\n * The Firebase products this provider builds, and therefore the ones every\n * page that renders it downloads.\n *\n * ⛔ NO Realtime Database and NO Cloud Storage (AGL-2706). Both were built\n * here and published as `useDatabase()` / `useStorage()`, and neither hook had\n * a single caller anywhere in the repo — so `@firebase/database` (125.9 KB\n * raw) and `@firebase/storage` (13.9 KB) were in the first load of every\n * console route to satisfy a context field nothing read.\n *\n * Realtime Database IS used, by `apps/console/hooks/use-presence.ts` and\n * `use-coediting.ts` — on their own Firebase app, with their own\n * `getDatabase`, behind the editor routes that need presence. That is the\n * shape to copy if a surface needs one of these: reach for the product where\n * the feature is, not in the provider every page mounts. A field here is paid\n * for by every route, used or not.\n */\ninterface FirebaseServices {\n app: FirebaseApp\n firestore: Firestore\n auth: Auth\n analytics: Analytics\n remoteConfig: RemoteConfig\n /**\n * The persistence class `auth` was actually created with (AGL-1379).\n *\n * Published so a **second** auth instance derives its class from the\n * primary rather than re-deciding it. `apps/console/hooks/use-presence.ts`\n * builds its own Firebase app and its own `Auth`; before this it took the\n * SDK default unconditionally, so configuring only the provider would have\n * left presence persisting a refresh token on an origin the provider had\n * just been careful not to persist one on.\n */\n authPersistence: AuthPersistenceClass\n}\n\nconst FirebaseServicesContext = createContext<FirebaseServices | undefined>(undefined)\n\n// Module-scope guards so HMR / remounts don't re-run emulator connection\n// (which throws if called twice) or double-initialize Firestore's\n// persistent cache.\n//\n// Firestore's is keyed by app NAME, not a bare boolean (AGL-1456): Firestore\n// initialization is once-per-app, so a module-global flag made the FIRST app\n// to mount decide for every later one — a second app would silently skip\n// `initializeFirestore` and take SDK defaults. That mattered the moment the\n// cache stopped being the same on every host.\nconst firestoreInitialized = new Set<string>()\nlet connectedAuth = false\n\n/**\n * True when THIS Chrome instance is under WebDriver automation control\n * (Puppeteer, Playwright, Selenium, or a raw CDP client) — never true for a\n * person's own browser.\n *\n * The `initializeFirestore` call below already documents the fault this\n * exists for: \"the emulator's WebChannel streaming misbehaves in automated\n * Chrome — listeners serve the initial empty from-cache snapshot and the\n * server sync never arrives\" (AGL-217). That mitigation only reaches\n * `FIREBASE_FIRESTORE_EMULATOR_ENABLED`, so an automated session against a\n * REAL, deployed backend — a browser-automation agent driving a live\n * console, which is exactly what `navigator.webdriver` detects — stayed\n * exposed. `getDocs`/`getDoc` and `onSnapshot` all resolve over the same\n * Watch stream internally, so a one-shot read wedges exactly like a listener\n * does: no error, nothing to catch, `loading` simply never gets its\n * settling event. That is the Media Library grid spinning on \"Loading\n * media…\" indefinitely with a perfectly healthy account and a perfectly\n * healthy network.\n *\n * `undefined` during SSR reads as `false` — `navigator` does not exist in\n * Node, and a server render can only ever produce the auto-detected\n * transport, never the forced one.\n */\nconst isAutomatedChromeSession = (): boolean =>\n typeof navigator !== 'undefined' && navigator.webdriver === true\n\n/**\n * The console tag's gtag config — ONE object, at module scope, deliberately\n * (AGL-1979).\n *\n * `initializeAnalytics(app, options)` throws `already-initialized` unless the\n * options `deepEqual` the ones the instance was first created with, and the\n * provider re-enters this block on every mount, every StrictMode double\n * invoke and every Fast Refresh. A fresh object literal per call survives\n * that only by *value*; a shared constant survives it by *identity*, which is\n * the first branch of the SDK's own comparison and cannot drift when someone\n * edits one call site and not another.\n *\n * ⚠️ The SDK MUTATES this object: `_initializeAnalytics` reads `options.config`\n * and writes `origin`, `update` and the installation id onto it in place.\n * That is precisely why sharing it is safe rather than dangerous — the object\n * the provider holds and the object the SDK stored are the same one, so they\n * cannot disagree.\n */\nconst CONSOLE_ANALYTICS_OPTIONS = {\n config: { send_page_view: false, content_group: 'console' },\n}\n\n/**\n * A surface's answer to \"may an analytics tag exist for this visitor at all?\".\n *\n * Registered from the app, at module scope, in the same shape as\n * `setFirestoreSessionReporters` and `setStaleSessionCheck` — and for a\n * sharper version of the same reason. This is consulted during the provider's\n * FIRST render, before any effect anywhere has run, so a gate installed from\n * an effect would arrive after the decision it exists to make.\n *\n * Answering `false` means the SDK is never asked to initialize: `gtag.js` is\n * never fetched, `window.gtag` never comes into existence, and the shared\n * `deliver()` in `analytics-events.ts` — whose fallback IS `window.gtag` —\n * therefore has nowhere to put a hit. That is the difference between a gate\n * and a suppression, and it is the difference AGL-1608 paid for: a resident\n * tag reports on its own, through enhanced measurement, whatever any flag\n * says afterwards.\n */\nexport type AnalyticsConsentGate = () => boolean\n\nlet analyticsConsentGate: AnalyticsConsentGate | null = null\n\n/**\n * Install the consent gate for this document, or clear it with `null`.\n *\n * UNREGISTERED MEANS ALLOWED, which is deliberate and is the only default that\n * is safe here. The tenant runtime does not use this provider's analytics\n * branch at all (it builds its own tag in `site-analytics.tsx`, behind the\n * AGL-1498 gate), and a self-hosted console points at the operator's own GA\n * property — silencing an operator's analytics because Aglyn's own surface\n * grew a gate is a failure they cannot diagnose. The surface that needs the\n * gate is the one that registers it.\n */\nexport function setAnalyticsConsentGate(\n gate: AnalyticsConsentGate | null,\n): void {\n analyticsConsentGate = gate\n}\n\n/** The gate's verdict, with an unregistered gate reading as \"allowed\". */\nfunction analyticsConsentAllows(): boolean {\n if (analyticsConsentGate === null) return true\n try {\n return analyticsConsentGate() === true\n } catch {\n // A gate that throws cannot be read as consent. Storage can throw in\n // private mode, and \"we could not tell\" is the one state this whole\n // mechanism resolves to \"no\".\n return false\n }\n}\n\n/**\n * The Analytics instance per Firebase app, so a re-boot after a consent change\n * hands back the tag that is already resident rather than asking the SDK for a\n * second one.\n *\n * `initializeAnalytics` is idempotent while the options match, so this is not\n * what makes re-entry safe — `CONSOLE_ANALYTICS_OPTIONS` being a shared\n * constant is. What this avoids is the noisy path: once anything has\n * initialized the provider with different options, every later call throws and\n * is logged, and a withdrawal followed by a re-grant would log it again.\n */\nconst analyticsByApp = new WeakMap<FirebaseApp, Analytics>()\n\n/**\n * Boot the console's Analytics tag, or return the one already booted.\n *\n * Extracted from the provider body so that \"when is the tag created\" and \"may\n * it be created\" are separable: the consent gate can defer this call to a\n * later render without the ordering below moving.\n */\nfunction bootAnalytics(app: FirebaseApp): Analytics | undefined {\n const existing = analyticsByApp.get(app)\n if (existing) return existing\n let analytics: Analytics\n // The region-conditional consent default (AGL-1597), declared BEFORE the\n // SDK boots the tag. Ordering is the whole of it: a `default` read after\n // `config` is not a default, and the SDK issues `config` inside\n // `initializeAnalytics` on the next line. Pushing onto `dataLayer` here\n // works even though gtag.js is not loaded yet — the queue is what makes the\n // ordering expressible.\n //\n // Still declared even though the gate above has already said yes, and the\n // two are not redundant. The gate answers \"may a tag exist\"; the default\n // answers \"what may the tag that does exist do\", per region, from its very\n // first hit. Ad storage stays denied everywhere on this surface.\n pushPlatformConsentDefault(\n typeof window === 'undefined' ? null : (window as never),\n )\n try {\n analytics = initializeAnalyticsInstance(app, CONSOLE_ANALYTICS_OPTIONS)\n } catch (error) {\n console.error(error)\n try {\n // Already initialized by someone else, with someone else's options:\n // take THAT instance rather than leaving consumers with nothing. The\n // tag it is attached to was configured without `send_page_view: false`\n // and without `content_group`, so this session reports a duplicate\n // startup page_view and an unstamped surface — degraded, and loudly so,\n // instead of silently dead.\n analytics = getAnalyticsInstance(app)\n } catch (fallbackError) {\n console.error(fallbackError)\n }\n }\n if (analytics) analyticsByApp.set(app, analytics)\n return analytics\n}\n\n/**\n * The tag this document may have right now: none outside a real production\n * deployment, none while consent is absent, otherwise the booted instance.\n */\nfunction analyticsForConsentState(app: FirebaseApp): Analytics | undefined {\n if (!analyticsMayEmit()) return undefined\n if (!analyticsConsentAllows()) return undefined\n return bootAnalytics(app)\n}\n\nexport interface FirebaseServicesProviderProps {\n firebaseConfig: FirebaseOptions\n appName: string\n /**\n * How much of *anything* this origin may keep on disk. Defaults to\n * `durable`, which is what every host serving this provider today is:\n * `*.aglyn.com` and `*.aglyn.app`, whose DNS we own and whose 14-day\n * session is the product.\n *\n * A **custom console domain** (AGL-1099c) must pass `ephemeral`. That is\n * the whole reason this is a prop rather than a constant: the class differs\n * per origin, not per build, and the decision belongs to whoever knows\n * which origin is being served.\n *\n * **It governs two things, and the name only names one.** It selects the\n * `Auth` persistence class (AGL-1379) *and* the Firestore `localCache`\n * (AGL-1456) — because both answer the same question about the same origin,\n * and splitting them into two props is precisely how the cache came to be\n * unconditional while the credential was hardened. See `firestore-cache.ts`.\n */\n authPersistence?: AuthPersistenceClass\n children?: ReactNode\n}\n\n/** The services every surface boots, before the per-document analytics verdict. */\ninterface FirebaseCoreServices {\n app: FirebaseApp\n firestore: Firestore\n auth: Auth\n remoteConfig: RemoteConfig\n}\n\n/**\n * Create (or adopt) the app, Auth, Firestore, App Check and Remote Config for\n * one Firebase app: everything the provider used to build in its first render\n * except Analytics, whose consent verdict belongs to the render that reads it.\n */\nfunction bootFirebaseCore(\n firebaseConfig: FirebaseOptions,\n appName: string,\n authPersistence: AuthPersistenceClass,\n): FirebaseCoreServices {\n const app =\n getApps().find((existing) => existing.name === appName) ??\n initializeApp(firebaseConfig, appName)\n // `durable` resolves to the same `getAuth(app)` this always called.\n const auth = createAuthInstance(app, authPersistence)\n\n // The origin's class, unless this tab fell back to the memory cache\n // because another tab locked the shared one (AGL-3428). Firestore only:\n // `auth` above keeps the origin's class.\n const firestoreCacheClass = firestoreCacheClassFor(authPersistence)\n if (!firestoreInitialized.has(appName)) {\n try {\n // Under the emulator (dev/e2e only): force long-polling and skip\n // the persistent multi-tab cache. The emulator's WebChannel\n // streaming misbehaves in automated Chrome — listeners serve the\n // initial empty from-cache snapshot and the server sync never\n // arrives, which looks like \"empty pages with zero errors\"\n // (the AGL-217 mystery). Real traffic keeps the default transport\n // and persistent cache — except a WebDriver-controlled Chrome\n // against a real backend, which forces long-polling for the same\n // reason without giving up the cache (see `isAutomatedChromeSession`\n // below).\n //\n // ⚠️ CONSEQUENCE, and it will cost you hours if you do not know it\n // (AGL-1066): every stale-session/stale-cache fault is UNREPRODUCIBLE\n // locally through the app, because the cache that causes them is off\n // in exactly the configuration you would reach for to reproduce one.\n // A listener that keeps serving arbitrarily-old data while the server\n // refuses it, a `noDocument` tombstone that 404s a live host, the\n // retry budget that never spends because a cached emission resets it\n // — none of them can happen here. Test that behaviour with the unit\n // seams instead (`use-firestore-collection-cached-retry.spec.ts`\n // drives cached emissions and denials directly), or against a\n // deployed environment. Do NOT \"fix\" it by turning the cache on for\n // the emulator: the AGL-217 empty-pages-with-zero-errors mystery is\n // what that produces.\n initializeFirestore(\n app,\n FIREBASE_FIRESTORE_EMULATOR_ENABLED\n ? { experimentalForceLongPolling: true }\n : {\n // NOT unconditional (AGL-1456). `persistentLocalCache` writes\n // document bodies to this origin's IndexedDB, so on a custom\n // console domain it is the same exposure D6 removed from the\n // refresh token — see `firestore-cache.ts` for why one\n // declaration governs both.\n localCache: localCacheFor(firestoreCacheClass),\n // The real-backend half of the AGL-217 mitigation above: force\n // long-polling for a WebDriver-controlled Chrome even when it\n // is NOT talking to the emulator, because the WebChannel wedge\n // is a property of automated Chrome, not of which backend it\n // is automating against. Never true for a real visitor, so\n // production traffic keeps the SDK's own auto-detected\n // transport untouched.\n ...(isAutomatedChromeSession()\n ? { experimentalForceLongPolling: true }\n : {}),\n },\n )\n if (FIREBASE_FIRESTORE_EMULATOR_ENABLED) {\n // Where the server was told the emulator is, rather than a default\n // port another session's stack may hold (AGL-2834).\n const emulator = firestoreEmulatorHost()\n connectFirestoreEmulator(\n getFirestore(app),\n emulator.host,\n emulator.port,\n )\n } else {\n // The SDK never sweeps the multi-tab records the durable cache\n // strands in localStorage, and a full localStorage fails the whole\n // Firestore client — see `firestore-shared-client-state.ts`.\n void pruneSharedClientStateFor(firestoreCacheClass, app)\n // Only the durable multi-tab cache can be locked by another tab,\n // so only it gets the lease check after a stalled recovery.\n if (firestoreCacheClass === 'durable' && app.options?.projectId) {\n markMultiTabFirestore(\n getFirestore(app),\n `${firestorePersistencePrefix(app.name, app.options.projectId)}main`,\n )\n }\n }\n } catch {\n // already initialized (e.g. HMR reset the module flag) — getFirestore() returns the existing instance\n } finally {\n firestoreInitialized.add(appName)\n }\n }\n const firestore = getFirestore(app)\n\n if (!connectedAuth) {\n try {\n if (FIREBASE_AUTH_EMULATOR_ENABLED) {\n // The emulator the server was started with (AGL-2834).\n connectAuthEmulator(auth, authEmulatorUrl())\n }\n connectedAuth = true\n } catch (error) {\n console.error(error)\n }\n }\n // App Check must be skipped under the emulators: there is no App\n // Check emulator, so ReCaptcha would hit the real backend and its\n // 403s break emulator auth (the AGL-216 emulator sessions hit this).\n if (\n !FIREBASE_AUTH_EMULATOR_ENABLED &&\n !FIREBASE_FIRESTORE_EMULATOR_ENABLED\n ) {\n // No site key means no provider (AGL-2049). Registering one built on\n // `undefined` does not throw — it fails asynchronously inside the SDK,\n // where the catch below cannot see it — so this has to be a pre-check.\n const siteKey = appCheckSiteKey()\n if (!siteKey) {\n console.warn(APP_CHECK_KEY_MISSING_MESSAGE)\n } else {\n try {\n initializeAppCheck(app, {\n provider: new ReCaptchaV3Provider(siteKey),\n isTokenAutoRefreshEnabled: true,\n })\n } catch (error) {\n console.error(error)\n }\n }\n }\n // Remote Config (AGL-228): release-flag delivery. Browser-only like\n // analytics; consumers set defaultConfig before their first getValue so\n // gating never blocks on the network.\n let remoteConfig: RemoteConfig\n try {\n remoteConfig = getRemoteConfigInstance(app)\n remoteConfig.settings.minimumFetchIntervalMillis =\n process.env.NODE_ENV === 'production' ? 3_600_000 : 60_000\n } catch (error) {\n console.error(error)\n }\n\n return { app, firestore, auth, remoteConfig }\n}\n\n/**\n * Services booted before React rendered anything, waiting for the provider to\n * adopt them (AGL-3660).\n *\n * The provider builds its services in its first render, and on the console\n * that render comes late: after every chunk of the route has executed, after\n * hydration, and after the `NoSsr` boundary above it has committed its\n * fallback and rendered again. Auth's persistence read and `accounts:lookup`,\n * and App Check's reCAPTCHA chain (script, script, iframe, token exchange),\n * which Auth AND Firestore requests both wait on whenever the cached App Check\n * token has expired, only started then. None of it depends on React, so a\n * surface that knows its config at module scope can start it there.\n *\n * Taken once, by the first provider that renders with the same app name and\n * persistence class; any later mount boots exactly as it always did. A\n * mismatched key is never adopted, so a preboot cannot hand a provider a\n * different persistence class than the one it was given.\n */\nconst prebooted = new Map<string, FirebaseCoreServices>()\nconst prebootKey = (appName: string, authPersistence: AuthPersistenceClass) =>\n `${appName}|${authPersistence}`\n\n/**\n * Start Firebase for this document now rather than at the provider's first\n * render. Browser only, once per app name and persistence class, and it never\n * throws: a failure here leaves the provider to boot as it always did.\n *\n * The Analytics consent gate is NOT read here (the provider reads it), so the\n * gate's module-scope registration does not have to precede this call.\n */\nexport function prebootFirebaseServices(options: {\n firebaseConfig: FirebaseOptions\n appName: string\n authPersistence?: AuthPersistenceClass\n}): void {\n if (typeof window === 'undefined') return\n const { firebaseConfig, appName, authPersistence = 'durable' } = options\n const key = prebootKey(appName, authPersistence)\n if (prebooted.has(key)) return\n try {\n prebooted.set(key, bootFirebaseCore(firebaseConfig, appName, authPersistence))\n } catch (error) {\n console.error(error)\n }\n}\n\nfunction takePrebootedFirebaseCore(\n appName: string,\n authPersistence: AuthPersistenceClass,\n): FirebaseCoreServices | undefined {\n const key = prebootKey(appName, authPersistence)\n const core = prebooted.get(key)\n if (core) prebooted.delete(key)\n return core\n}\n\nexport function FirebaseServicesProvider(props: FirebaseServicesProviderProps) {\n const { firebaseConfig, appName, authPersistence = 'durable', children } = props\n const servicesRef = useRef<FirebaseServices | undefined>(undefined)\n\n if (!servicesRef.current) {\n const core =\n takePrebootedFirebaseCore(appName, authPersistence) ??\n bootFirebaseCore(firebaseConfig, appName, authPersistence)\n // `initializeAnalytics`, not `getAnalytics`, for exactly one reason: it is\n // the only form that can pass `config`, and `send_page_view: false` is the\n // only way to stop the SDK's own startup `page_view` (AGL-1643).\n //\n // Booting Analytics issues `gtag('config', <id>, configProperties)`, and\n // the vendored SDK's own comment on that line reads \"This will trigger a\n // page_view event unless 'send_page_view' is set to false in\n // configProperties\". `getAnalytics(app)` passes no config at all, so the\n // key was never there and the hit always fired — on top of the manual\n // `page_view` the console's layout sends on mount. Two hits, one page.\n //\n // The SDK's is the one suppressed rather than the layout's, and the\n // direction is the whole decision: the SDK fires ONCE per document load,\n // while the layout fires on mount AND on every `usePathname` change. In a\n // client-routed app the layout's is a superset — killing it instead would\n // have left every in-app navigation unreported, halving console pageviews\n // rather than correcting them, and the reports would have looked fine.\n //\n // Nothing else about attribution moves: the surviving hit is sent from the\n // same document at mount, so `document.referrer` — which gtag resolves for\n // `page_referrer` itself, and which is what carries marketing traffic\n // source into the session — is still the external referrer at that moment.\n //\n // Re-entry is safe *only* while the options match. `initializeAnalytics`\n // returns the existing instance when they deep-equal the first call's and\n // throws `already-initialized` otherwise — and the options can be made to\n // differ by things no call site here controls (AGL-1979):\n //\n // * anything that touches the analytics provider WITHOUT options first\n // initializes it with `{}`. `getAnalytics()` is the obvious one, but\n // `@firebase/remote-config` reaches the same code path on its own —\n // `addExperimentToAnalytics` calls `analyticsProvider.getImmediate({\n // optional: true })`, and `optional` only suppresses the throw, it\n // does not stop the initialization.\n // * in dev, a module instance carrying a PREVIOUS version of these\n // options (this config changed under AGL-1857) surviving a Fast\n // Refresh alongside the new one.\n //\n // Once that happens the conflict is permanent for the document: every\n // later call throws, so without the fallback below `analytics` stayed\n // undefined forever and the next consumer — `logEvent(analytics, …)` —\n // read `.app` off it and threw a TypeError two frames away, which is how\n // this surfaced (top Cloud Error Reporting group, and reproduced live on\n // app.aglyn.com).\n //\n // `getAnalytics(app)` is the remedy the SDK's own error message names. It\n // cannot cost us the config on a healthy document: it only runs after\n // `initializeAnalytics` threw, and on a first init that throw can only be\n // `no-app-id` / `no-api-key` / `already-exists`, none of which\n // `getAnalytics` can rescue either — it re-throws and we fall through to\n // an undefined instance, which every consumer must now tolerate.\n // `content_group: 'console'` (AGL-1857) rides the same config: it is the\n // first-class GA4 axis that separates console traffic from `marketing`\n // (stamped by the tenant runtime on aglyn.com's own tag) and `docs`\n // (stamped by the Docusaurus head snippet) in standard reports, without\n // reaching for the Hostname dimension. The console is this provider's\n // only consumer, verified by grep — the tenant runtime builds its tag in\n // `site-analytics.tsx` and never passes through here.\n //\n // AND NOT AT ALL outside a real production deployment (AGL-2067). This\n // provider used to boot Analytics unconditionally, while\n // `apps/console/.env.development.local` points at the PRODUCTION\n // measurement id — so every `next dev` session and every Vercel preview\n // build produced genuine `session_start` / `first_visit` / `page_view`\n // hits in the live property. Not a theory: the archived Marketing\n // property's entire year-to-date history is 30 views / 6 users, ~24 of\n // them `/signin` on preview urls (docs/ANALYTICS.md).\n //\n // Not initialized rather than initialized-and-suppressed, because a\n // resident tag reports on its own — AGL-1608 is the same lesson from the\n // consent side. `useAnalytics()` returning undefined is already a\n // supported state everywhere (AGL-1979), which is what makes this a\n // two-line gate instead of an audit.\n //\n // `analyticsMayEmit` leans LOUD on an unknown deployment: a self-hosted\n // build points at the operator's own Firebase project and their own GA\n // property, and silencing a customer's analytics to protect ours is the\n // worse failure. See the module for the escape hatch.\n //\n // AND NOT while a registered consent gate says no (AGL-1498 posture,\n // applied to Aglyn's own console). `analyticsForConsentState` is where\n // both conditions and the boot itself now live, because the boot has to\n // be repeatable: a visitor whose region has not resolved yet is undecided\n // at this instant and may be granted a moment later, and a visitor who\n // withdraws mid-session has to lose the instance without the page\n // reloading. See the effect below.\n //\n // SCOPE. This is the console's tag and only the console's:\n // `CONSOLE_ANALYTICS_OPTIONS`, `content_group: 'console'`, and this\n // provider's analytics branch has no other consumer (the tenant runtime\n // builds its tag in `site-analytics.tsx` and never passes through here).\n // Customer sites keep the AGL-1498 gate and the host's own\n // `consent.mode`; nothing here reaches them.\n const initialAnalytics = analyticsForConsentState(core.app)\n\n servicesRef.current = {\n ...core,\n analytics: initialAnalytics,\n authPersistence,\n }\n }\n\n const services = servicesRef.current\n\n // The consent gate's other half: the verdict can CHANGE within one document,\n // in both directions, and everything else in this provider is built exactly\n // once by design.\n //\n // Deferred grant. A visitor with no stored record is undecided at the\n // instant this provider first renders, because resolving them needs the\n // region endpoint and that is a network call. Outside the prior-consent\n // regions the answer is implied consent, and it lands a few hundred\n // milliseconds later — same document, so `document.referrer` is still the\n // external referrer and the pageview's attribution is unaffected. Without\n // this, \"the gate is synchronous\" would have quietly meant \"the rest of the\n // world is never measured on a first visit\".\n //\n // Withdrawal. Dropping the instance unmounts every binding hung off it,\n // which is what unregisters the analytics transport — after that\n // `deliver()` falls through to `window.gtag`, and the shared consent writer\n // has already set `ga-disable-<id>` and sent a denied `consent update` on\n // that exact tag (AGL-1608). The resident tag cannot be unloaded; it can be\n // made to send nothing, and both halves are needed because either alone\n // leaks — enhanced measurement fires with no call site at all.\n const [analytics, setAnalytics] = useState<Analytics | undefined>(\n () => services.analytics,\n )\n useEffect(() => {\n const sync = () => setAnalytics(analyticsForConsentState(services.app))\n // Once on mount as well as on the event: the priming pass can resolve a\n // visitor between this provider's render and this effect, and the event it\n // dispatched then had no listener yet.\n sync()\n window.addEventListener(VISITOR_CONSENT_CHANGED_EVENT, sync)\n return () =>\n window.removeEventListener(VISITOR_CONSENT_CHANGED_EVENT, sync)\n }, [services])\n\n // Identity changes only when the tag comes or goes, so a consent-stable\n // document re-renders nothing.\n const value = useMemo(\n () =>\n services.analytics === analytics ? services : { ...services, analytics },\n [services, analytics],\n )\n\n return (\n <FirebaseServicesContext.Provider value={value}>\n {children}\n </FirebaseServicesContext.Provider>\n )\n}\nFirebaseServicesProvider.displayName = 'FirebaseServicesProvider'\n\nfunction useFirebaseServices(): FirebaseServices {\n const services = useContext(FirebaseServicesContext)\n if (!services) {\n throw new Error(\n 'Firebase hooks must be used within a <FirebaseServicesProvider>',\n )\n }\n return services\n}\n\nexport function useFirebaseApp(): FirebaseApp {\n return useFirebaseServices().app\n}\nexport function useFirestore(): Firestore {\n return useFirebaseServices().firestore\n}\nexport function useAuth(): Auth {\n return useFirebaseServices().auth\n}\n/**\n * The persistence class the provider's `Auth` was created with (AGL-1379).\n *\n * Read it — never re-derive it — when building a **second** Firebase app on\n * the same origin, so the second instance cannot end up more persistent than\n * the first. `apps/console/hooks/use-presence.ts` is the existing case.\n */\nexport function useAuthPersistence(): AuthPersistenceClass {\n return useFirebaseServices().authPersistence\n}\nexport function useAnalytics(): Analytics {\n return useFirebaseServices().analytics\n}\nexport function useRemoteConfig(): RemoteConfig {\n return useFirebaseServices().remoteConfig\n}\n\n/**\n * Replaces reactfire's `useUser()`. Reactfire subscribes via\n * `onIdTokenChanged` rather than `onAuthStateChanged` so fields like\n * `emailVerified` stay live across token refresh (e.g. after\n * `user.reload()` post-email-verification), not just sign-in/out — matched\n * here for behavioral parity.\n *\n * THREE states, not two (AGL-1261): `undefined` = auth has not resolved yet,\n * `null` = resolved, signed OUT, a `User` = resolved, signed in. This used to\n * collapse the emitted `null` into `undefined`, which made \"signed out\" and\n * \"still loading\" the same value — a state that, for a signed-out visitor,\n * never left \"loading\". `useSessionCookie` gates its whole body on\n * `user === undefined`, so its silent restore from the shared `__session`\n * cookie could never run: the cookie exchange returned a valid custom token\n * and nothing ever asked for it.\n */\nexport function useUser(): { data: User | null | undefined } {\n const auth = useAuth()\n const [user, setUser] = useState<User | null | undefined>(\n auth.currentUser ?? undefined,\n )\n\n useEffect(() => {\n return onIdTokenChanged(auth, (nextUser) => {\n // `?? null`, never `?? undefined` — see the note above.\n setUser(nextUser ?? null)\n })\n }, [auth])\n\n return { data: user }\n}\n\nexport interface SigninCheckResult {\n signedIn: boolean\n user: User | null\n}\n\n/** Replaces reactfire's `useSigninCheck()` — same `onIdTokenChanged` basis as `useUser()`. */\nexport function useSigninCheck(): {\n status: 'loading' | 'success' | 'error'\n data: SigninCheckResult | undefined\n error: Error | undefined\n} {\n const auth = useAuth()\n const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')\n const [data, setData] = useState<SigninCheckResult | undefined>(undefined)\n const [error, setError] = useState<Error | undefined>(undefined)\n\n useEffect(() => {\n return onIdTokenChanged(\n auth,\n (user) => {\n setStatus('success')\n setError(undefined)\n setData({ signedIn: !!user, user: user ?? null })\n },\n (err) => {\n setStatus('error')\n setError(err)\n },\n )\n }, [auth])\n\n return { status, data, error }\n}\n"],"names":["getApps","initializeApp","getAnalytics","getAnalyticsInstance","initializeAnalytics","initializeAnalyticsInstance","initializeAppCheck","ReCaptchaV3Provider","analyticsMayEmit","pushPlatformConsentDefault","VISITOR_CONSENT_CHANGED_EVENT","connectAuthEmulator","onIdTokenChanged","connectFirestoreEmulator","getFirestore","initializeFirestore","getRemoteConfig","getRemoteConfigInstance","createContext","useContext","useEffect","useMemo","useRef","useState","FIREBASE_AUTH_EMULATOR_ENABLED","FIREBASE_FIRESTORE_EMULATOR_ENABLED","APP_CHECK_KEY_MISSING_MESSAGE","appCheckSiteKey","authEmulatorUrl","firestoreEmulatorHost","createAuthInstance","firestoreCacheClassFor","localCacheFor","pruneSharedClientStateFor","markMultiTabFirestore","firestorePersistencePrefix","FirebaseServicesContext","undefined","firestoreInitialized","Set","connectedAuth","isAutomatedChromeSession","navigator","webdriver","CONSOLE_ANALYTICS_OPTIONS","config","send_page_view","content_group","analyticsConsentGate","setAnalyticsConsentGate","gate","analyticsConsentAllows","analyticsByApp","WeakMap","bootAnalytics","app","existing","get","analytics","window","error","console","fallbackError","set","analyticsForConsentState","bootFirebaseCore","firebaseConfig","appName","authPersistence","find","name","auth","firestoreCacheClass","has","experimentalForceLongPolling","localCache","emulator","host","port","options","projectId","add","firestore","siteKey","warn","provider","isTokenAutoRefreshEnabled","remoteConfig","settings","minimumFetchIntervalMillis","process","env","NODE_ENV","prebooted","Map","prebootKey","prebootFirebaseServices","key","takePrebootedFirebaseCore","core","delete","FirebaseServicesProvider","props","children","servicesRef","current","initialAnalytics","services","setAnalytics","sync","addEventListener","removeEventListener","value","Provider","displayName","useFirebaseServices","Error","useFirebaseApp","useFirestore","useAuth","useAuthPersistence","useAnalytics","useRemoteConfig","useUser","user","setUser","currentUser","nextUser","data","useSigninCheck","status","setStatus","setData","setError","signedIn","err"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/firebase/firebase-services.tsx"],"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 {\n type FirebaseApp,\n type FirebaseOptions,\n getApps,\n initializeApp,\n} from 'firebase/app'\nimport {\n type Analytics,\n getAnalytics as getAnalyticsInstance,\n initializeAnalytics as initializeAnalyticsInstance,\n} from 'firebase/analytics'\nimport { initializeAppCheck, ReCaptchaV3Provider } from 'firebase/app-check'\nimport { analyticsMayEmit } from '@aglyn/aglyn/app-utils/analytics-environment'\nimport { pushPlatformConsentDefault } from '@aglyn/aglyn/app-utils/platform-consent-default'\nimport { VISITOR_CONSENT_CHANGED_EVENT } from '@aglyn/aglyn/app-utils/visitor-consent'\nimport {\n type Auth,\n connectAuthEmulator,\n onIdTokenChanged,\n type User,\n} from 'firebase/auth'\nimport {\n type Firestore,\n connectFirestoreEmulator,\n getFirestore,\n initializeFirestore,\n} from 'firebase/firestore'\nimport {\n type RemoteConfig,\n getRemoteConfig as getRemoteConfigInstance,\n} from 'firebase/remote-config'\nimport {\n createContext,\n useContext,\n useEffect,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react'\nimport {\n FIREBASE_AUTH_EMULATOR_ENABLED,\n FIREBASE_FIRESTORE_EMULATOR_ENABLED,\n} from '@aglyn/shared-data-enums'\nimport {\n APP_CHECK_KEY_MISSING_MESSAGE,\n appCheckSiteKey,\n} from '../../constants/firebase-config'\nimport {\n authEmulatorUrl,\n firestoreEmulatorHost,\n} from '../../constants/firebase-emulator-hosts'\nimport {\n type AuthPersistenceClass,\n createAuthInstance,\n} from './auth-persistence'\nimport {\n firestoreCacheClassFor,\n localCacheFor,\n pruneSharedClientStateFor,\n} from './firestore-cache'\nimport { markMultiTabFirestore } from './firestore-multitab-wedge'\nimport { firestorePersistencePrefix } from './firestore-shared-client-state'\nimport { startFirestoreTabHandoff } from './firestore-tab-handoff'\n\n/**\n * Drop-in replacement for reactfire's `ObservableStatus<T>` — reactfire is\n * unmaintained against firebase majors past v9, but its shape is preserved\n * here so `helpers/use-doc.ts`'s already-battle-tested onSnapshot+retry\n * implementation (AGL-216/223) didn't need to change.\n */\nexport interface ObservableStatus<T> {\n status: 'loading' | 'error' | 'success'\n hasEmitted: boolean\n isComplete: boolean\n data: T\n error: Error | undefined\n firstValuePromise: Promise<void>\n /**\n * The emitted `data` includes local writes the server has NOT\n * acknowledged (`snapshot.metadata.hasPendingWrites`).\n *\n * Carried rather than dropped because a caller cannot otherwise tell \"the\n * store holds this\" from \"my browser holds this\". `persistentLocalCache`\n * replays a queued write into the very first snapshot after a reload, so a\n * caller that treats every snapshot as authoritative will adopt its own\n * unacknowledged edit as the saved state — which is how the besigner ended\n * up unable to save real changes (AGL-1262).\n */\n hasPendingWrites: boolean\n /**\n * The emitted `data` came from the local cache and the server has NOT\n * confirmed it (`snapshot.metadata.fromCache`).\n *\n * Carried because it is the only per-listener ground truth about\n * freshness the SDK offers, and the console needs it (AGL-1066). Under\n * `persistentLocalCache` a listener whose server listen is being REFUSED\n * still emits cached snapshots and still reports `status: 'success'` —\n * the retry budget is reset by each cached emission and never spends, so\n * `status` can never reveal the fault (see\n * `use-firestore-collection-cached-retry.spec.ts`).\n *\n * Prefer this over `session-health`'s `staleSession` for anything that\n * asks \"is what I am looking at live?\". `staleSession` is a heuristic\n * needing two denied collections inside a window, and it is unreachable\n * on listener-only pages; this is a fact about THIS listener, costs\n * nothing, and clears itself the instant a server snapshot lands.\n */\n fromCache: boolean\n /**\n * The server has REFUSED this listen for longer than the retry budget, and\n * no server snapshot has arrived since (AGL-1066).\n *\n * `fromCache` says \"unconfirmed\", which is also true offline and true for\n * the first snapshot of a perfectly healthy load. This says \"refused\" —\n * only `permission-denied` counts it, and it takes a whole budget's worth\n * in a row, so it cannot fire offline and cannot fire on the AGL-216/217\n * post-sign-in token race.\n *\n * It is what `status` would say if the retry budget were spendable. It is\n * carried separately because correcting `status` in place would blank\n * every surface that renders cached data through an `error` branch — see\n * the note on `attempt = 0` in `use-firestore-collection`.\n */\n serverDenied: boolean\n}\n\n/** Replaces reactfire's `ReactFireOptions<T>` — drops the `suspense` field, which was never read. */\nexport interface FirestoreDocOptions<T> {\n idField?: string\n initialData?: T\n}\n\n/**\n * The Firebase products this provider builds, and therefore the ones every\n * page that renders it downloads.\n *\n * ⛔ NO Realtime Database and NO Cloud Storage (AGL-2706). Both were built\n * here and published as `useDatabase()` / `useStorage()`, and neither hook had\n * a single caller anywhere in the repo — so `@firebase/database` (125.9 KB\n * raw) and `@firebase/storage` (13.9 KB) were in the first load of every\n * console route to satisfy a context field nothing read.\n *\n * Realtime Database IS used, by `apps/console/hooks/use-presence.ts` and\n * `use-coediting.ts` — on their own Firebase app, with their own\n * `getDatabase`, behind the editor routes that need presence. That is the\n * shape to copy if a surface needs one of these: reach for the product where\n * the feature is, not in the provider every page mounts. A field here is paid\n * for by every route, used or not.\n */\ninterface FirebaseServices {\n app: FirebaseApp\n firestore: Firestore\n auth: Auth\n analytics: Analytics\n remoteConfig: RemoteConfig\n /**\n * The persistence class `auth` was actually created with (AGL-1379).\n *\n * Published so a **second** auth instance derives its class from the\n * primary rather than re-deciding it. `apps/console/hooks/use-presence.ts`\n * builds its own Firebase app and its own `Auth`; before this it took the\n * SDK default unconditionally, so configuring only the provider would have\n * left presence persisting a refresh token on an origin the provider had\n * just been careful not to persist one on.\n */\n authPersistence: AuthPersistenceClass\n}\n\nconst FirebaseServicesContext = createContext<FirebaseServices | undefined>(undefined)\n\n// Module-scope guards so HMR / remounts don't re-run emulator connection\n// (which throws if called twice) or double-initialize Firestore's\n// persistent cache.\n//\n// Firestore's is keyed by app NAME, not a bare boolean (AGL-1456): Firestore\n// initialization is once-per-app, so a module-global flag made the FIRST app\n// to mount decide for every later one — a second app would silently skip\n// `initializeFirestore` and take SDK defaults. That mattered the moment the\n// cache stopped being the same on every host.\nconst firestoreInitialized = new Set<string>()\nlet connectedAuth = false\n\n/**\n * True when THIS Chrome instance is under WebDriver automation control\n * (Puppeteer, Playwright, Selenium, or a raw CDP client) — never true for a\n * person's own browser.\n *\n * The `initializeFirestore` call below already documents the fault this\n * exists for: \"the emulator's WebChannel streaming misbehaves in automated\n * Chrome — listeners serve the initial empty from-cache snapshot and the\n * server sync never arrives\" (AGL-217). That mitigation only reaches\n * `FIREBASE_FIRESTORE_EMULATOR_ENABLED`, so an automated session against a\n * REAL, deployed backend — a browser-automation agent driving a live\n * console, which is exactly what `navigator.webdriver` detects — stayed\n * exposed. `getDocs`/`getDoc` and `onSnapshot` all resolve over the same\n * Watch stream internally, so a one-shot read wedges exactly like a listener\n * does: no error, nothing to catch, `loading` simply never gets its\n * settling event. That is the Media Library grid spinning on \"Loading\n * media…\" indefinitely with a perfectly healthy account and a perfectly\n * healthy network.\n *\n * `undefined` during SSR reads as `false` — `navigator` does not exist in\n * Node, and a server render can only ever produce the auto-detected\n * transport, never the forced one.\n */\nconst isAutomatedChromeSession = (): boolean =>\n typeof navigator !== 'undefined' && navigator.webdriver === true\n\n/**\n * The console tag's gtag config — ONE object, at module scope, deliberately\n * (AGL-1979).\n *\n * `initializeAnalytics(app, options)` throws `already-initialized` unless the\n * options `deepEqual` the ones the instance was first created with, and the\n * provider re-enters this block on every mount, every StrictMode double\n * invoke and every Fast Refresh. A fresh object literal per call survives\n * that only by *value*; a shared constant survives it by *identity*, which is\n * the first branch of the SDK's own comparison and cannot drift when someone\n * edits one call site and not another.\n *\n * ⚠️ The SDK MUTATES this object: `_initializeAnalytics` reads `options.config`\n * and writes `origin`, `update` and the installation id onto it in place.\n * That is precisely why sharing it is safe rather than dangerous — the object\n * the provider holds and the object the SDK stored are the same one, so they\n * cannot disagree.\n */\nconst CONSOLE_ANALYTICS_OPTIONS = {\n config: { send_page_view: false, content_group: 'console' },\n}\n\n/**\n * A surface's answer to \"may an analytics tag exist for this visitor at all?\".\n *\n * Registered from the app, at module scope, in the same shape as\n * `setFirestoreSessionReporters` and `setStaleSessionCheck` — and for a\n * sharper version of the same reason. This is consulted during the provider's\n * FIRST render, before any effect anywhere has run, so a gate installed from\n * an effect would arrive after the decision it exists to make.\n *\n * Answering `false` means the SDK is never asked to initialize: `gtag.js` is\n * never fetched, `window.gtag` never comes into existence, and the shared\n * `deliver()` in `analytics-events.ts` — whose fallback IS `window.gtag` —\n * therefore has nowhere to put a hit. That is the difference between a gate\n * and a suppression, and it is the difference AGL-1608 paid for: a resident\n * tag reports on its own, through enhanced measurement, whatever any flag\n * says afterwards.\n */\nexport type AnalyticsConsentGate = () => boolean\n\nlet analyticsConsentGate: AnalyticsConsentGate | null = null\n\n/**\n * Install the consent gate for this document, or clear it with `null`.\n *\n * UNREGISTERED MEANS ALLOWED, which is deliberate and is the only default that\n * is safe here. The tenant runtime does not use this provider's analytics\n * branch at all (it builds its own tag in `site-analytics.tsx`, behind the\n * AGL-1498 gate), and a self-hosted console points at the operator's own GA\n * property — silencing an operator's analytics because Aglyn's own surface\n * grew a gate is a failure they cannot diagnose. The surface that needs the\n * gate is the one that registers it.\n */\nexport function setAnalyticsConsentGate(\n gate: AnalyticsConsentGate | null,\n): void {\n analyticsConsentGate = gate\n}\n\n/** The gate's verdict, with an unregistered gate reading as \"allowed\". */\nfunction analyticsConsentAllows(): boolean {\n if (analyticsConsentGate === null) return true\n try {\n return analyticsConsentGate() === true\n } catch {\n // A gate that throws cannot be read as consent. Storage can throw in\n // private mode, and \"we could not tell\" is the one state this whole\n // mechanism resolves to \"no\".\n return false\n }\n}\n\n/**\n * The Analytics instance per Firebase app, so a re-boot after a consent change\n * hands back the tag that is already resident rather than asking the SDK for a\n * second one.\n *\n * `initializeAnalytics` is idempotent while the options match, so this is not\n * what makes re-entry safe — `CONSOLE_ANALYTICS_OPTIONS` being a shared\n * constant is. What this avoids is the noisy path: once anything has\n * initialized the provider with different options, every later call throws and\n * is logged, and a withdrawal followed by a re-grant would log it again.\n */\nconst analyticsByApp = new WeakMap<FirebaseApp, Analytics>()\n\n/**\n * Boot the console's Analytics tag, or return the one already booted.\n *\n * Extracted from the provider body so that \"when is the tag created\" and \"may\n * it be created\" are separable: the consent gate can defer this call to a\n * later render without the ordering below moving.\n */\nfunction bootAnalytics(app: FirebaseApp): Analytics | undefined {\n const existing = analyticsByApp.get(app)\n if (existing) return existing\n let analytics: Analytics\n // The region-conditional consent default (AGL-1597), declared BEFORE the\n // SDK boots the tag. Ordering is the whole of it: a `default` read after\n // `config` is not a default, and the SDK issues `config` inside\n // `initializeAnalytics` on the next line. Pushing onto `dataLayer` here\n // works even though gtag.js is not loaded yet — the queue is what makes the\n // ordering expressible.\n //\n // Still declared even though the gate above has already said yes, and the\n // two are not redundant. The gate answers \"may a tag exist\"; the default\n // answers \"what may the tag that does exist do\", per region, from its very\n // first hit. Ad storage stays denied everywhere on this surface.\n pushPlatformConsentDefault(\n typeof window === 'undefined' ? null : (window as never),\n )\n try {\n analytics = initializeAnalyticsInstance(app, CONSOLE_ANALYTICS_OPTIONS)\n } catch (error) {\n console.error(error)\n try {\n // Already initialized by someone else, with someone else's options:\n // take THAT instance rather than leaving consumers with nothing. The\n // tag it is attached to was configured without `send_page_view: false`\n // and without `content_group`, so this session reports a duplicate\n // startup page_view and an unstamped surface — degraded, and loudly so,\n // instead of silently dead.\n analytics = getAnalyticsInstance(app)\n } catch (fallbackError) {\n console.error(fallbackError)\n }\n }\n if (analytics) analyticsByApp.set(app, analytics)\n return analytics\n}\n\n/**\n * The tag this document may have right now: none outside a real production\n * deployment, none while consent is absent, otherwise the booted instance.\n */\nfunction analyticsForConsentState(app: FirebaseApp): Analytics | undefined {\n if (!analyticsMayEmit()) return undefined\n if (!analyticsConsentAllows()) return undefined\n return bootAnalytics(app)\n}\n\nexport interface FirebaseServicesProviderProps {\n firebaseConfig: FirebaseOptions\n appName: string\n /**\n * How much of *anything* this origin may keep on disk. Defaults to\n * `durable`, which is what every host serving this provider today is:\n * `*.aglyn.com` and `*.aglyn.app`, whose DNS we own and whose 14-day\n * session is the product.\n *\n * A **custom console domain** (AGL-1099c) must pass `ephemeral`. That is\n * the whole reason this is a prop rather than a constant: the class differs\n * per origin, not per build, and the decision belongs to whoever knows\n * which origin is being served.\n *\n * **It governs two things, and the name only names one.** It selects the\n * `Auth` persistence class (AGL-1379) *and* the Firestore `localCache`\n * (AGL-1456) — because both answer the same question about the same origin,\n * and splitting them into two props is precisely how the cache came to be\n * unconditional while the credential was hardened. See `firestore-cache.ts`.\n */\n authPersistence?: AuthPersistenceClass\n children?: ReactNode\n}\n\n/** The services every surface boots, before the per-document analytics verdict. */\ninterface FirebaseCoreServices {\n app: FirebaseApp\n firestore: Firestore\n auth: Auth\n remoteConfig: RemoteConfig\n}\n\n/**\n * Create (or adopt) the app, Auth, Firestore, App Check and Remote Config for\n * one Firebase app: everything the provider used to build in its first render\n * except Analytics, whose consent verdict belongs to the render that reads it.\n */\nfunction bootFirebaseCore(\n firebaseConfig: FirebaseOptions,\n appName: string,\n authPersistence: AuthPersistenceClass,\n): FirebaseCoreServices {\n const app =\n getApps().find((existing) => existing.name === appName) ??\n initializeApp(firebaseConfig, appName)\n // `durable` resolves to the same `getAuth(app)` this always called.\n const auth = createAuthInstance(app, authPersistence)\n\n // The origin's class, unless this tab fell back to the memory cache\n // because another tab locked the shared one (AGL-3428). Firestore only:\n // `auth` above keeps the origin's class.\n const firestoreCacheClass = firestoreCacheClassFor(authPersistence)\n if (!firestoreInitialized.has(appName)) {\n try {\n // Under the emulator (dev/e2e only): force long-polling and skip\n // the persistent multi-tab cache. The emulator's WebChannel\n // streaming misbehaves in automated Chrome — listeners serve the\n // initial empty from-cache snapshot and the server sync never\n // arrives, which looks like \"empty pages with zero errors\"\n // (the AGL-217 mystery). Real traffic keeps the default transport\n // and persistent cache — except a WebDriver-controlled Chrome\n // against a real backend, which forces long-polling for the same\n // reason without giving up the cache (see `isAutomatedChromeSession`\n // below).\n //\n // ⚠️ CONSEQUENCE, and it will cost you hours if you do not know it\n // (AGL-1066): every stale-session/stale-cache fault is UNREPRODUCIBLE\n // locally through the app, because the cache that causes them is off\n // in exactly the configuration you would reach for to reproduce one.\n // A listener that keeps serving arbitrarily-old data while the server\n // refuses it, a `noDocument` tombstone that 404s a live host, the\n // retry budget that never spends because a cached emission resets it\n // — none of them can happen here. Test that behaviour with the unit\n // seams instead (`use-firestore-collection-cached-retry.spec.ts`\n // drives cached emissions and denials directly), or against a\n // deployed environment. Do NOT \"fix\" it by turning the cache on for\n // the emulator: the AGL-217 empty-pages-with-zero-errors mystery is\n // what that produces.\n initializeFirestore(\n app,\n FIREBASE_FIRESTORE_EMULATOR_ENABLED\n ? { experimentalForceLongPolling: true }\n : {\n // NOT unconditional (AGL-1456). `persistentLocalCache` writes\n // document bodies to this origin's IndexedDB, so on a custom\n // console domain it is the same exposure D6 removed from the\n // refresh token — see `firestore-cache.ts` for why one\n // declaration governs both.\n localCache: localCacheFor(firestoreCacheClass),\n // The real-backend half of the AGL-217 mitigation above: force\n // long-polling for a WebDriver-controlled Chrome even when it\n // is NOT talking to the emulator, because the WebChannel wedge\n // is a property of automated Chrome, not of which backend it\n // is automating against. Never true for a real visitor, so\n // production traffic keeps the SDK's own auto-detected\n // transport untouched.\n ...(isAutomatedChromeSession()\n ? { experimentalForceLongPolling: true }\n : {}),\n },\n )\n if (FIREBASE_FIRESTORE_EMULATOR_ENABLED) {\n // Where the server was told the emulator is, rather than a default\n // port another session's stack may hold (AGL-2834).\n const emulator = firestoreEmulatorHost()\n connectFirestoreEmulator(\n getFirestore(app),\n emulator.host,\n emulator.port,\n )\n } else {\n // The SDK never sweeps the multi-tab records the durable cache\n // strands in localStorage, and a full localStorage fails the whole\n // Firestore client — see `firestore-shared-client-state.ts`.\n void pruneSharedClientStateFor(firestoreCacheClass, app)\n // Only the durable multi-tab cache can be locked by another tab,\n // so only it gets the lease check after a stalled recovery.\n if (firestoreCacheClass === 'durable' && app.options?.projectId) {\n const prefix = firestorePersistencePrefix(app.name, app.options.projectId)\n markMultiTabFirestore(getFirestore(app), `${prefix}main`)\n // A visible tab opened while a background tab holds the shared\n // cache's lease takes the lease at boot instead of routing every\n // query through the background tab until its lease lapses\n // (AGL-3660). In initializeFirestore's task: its first step\n // snapshots the other tabs before this tab's client can start.\n void startFirestoreTabHandoff(getFirestore(app), prefix)\n }\n }\n } catch {\n // already initialized (e.g. HMR reset the module flag) — getFirestore() returns the existing instance\n } finally {\n firestoreInitialized.add(appName)\n }\n }\n const firestore = getFirestore(app)\n\n if (!connectedAuth) {\n try {\n if (FIREBASE_AUTH_EMULATOR_ENABLED) {\n // The emulator the server was started with (AGL-2834).\n connectAuthEmulator(auth, authEmulatorUrl())\n }\n connectedAuth = true\n } catch (error) {\n console.error(error)\n }\n }\n // App Check must be skipped under the emulators: there is no App\n // Check emulator, so ReCaptcha would hit the real backend and its\n // 403s break emulator auth (the AGL-216 emulator sessions hit this).\n if (\n !FIREBASE_AUTH_EMULATOR_ENABLED &&\n !FIREBASE_FIRESTORE_EMULATOR_ENABLED\n ) {\n // No site key means no provider (AGL-2049). Registering one built on\n // `undefined` does not throw — it fails asynchronously inside the SDK,\n // where the catch below cannot see it — so this has to be a pre-check.\n const siteKey = appCheckSiteKey()\n if (!siteKey) {\n console.warn(APP_CHECK_KEY_MISSING_MESSAGE)\n } else {\n try {\n initializeAppCheck(app, {\n provider: new ReCaptchaV3Provider(siteKey),\n isTokenAutoRefreshEnabled: true,\n })\n } catch (error) {\n console.error(error)\n }\n }\n }\n // Remote Config (AGL-228): release-flag delivery. Browser-only like\n // analytics; consumers set defaultConfig before their first getValue so\n // gating never blocks on the network.\n let remoteConfig: RemoteConfig\n try {\n remoteConfig = getRemoteConfigInstance(app)\n remoteConfig.settings.minimumFetchIntervalMillis =\n process.env.NODE_ENV === 'production' ? 3_600_000 : 60_000\n } catch (error) {\n console.error(error)\n }\n\n return { app, firestore, auth, remoteConfig }\n}\n\n/**\n * Services booted before React rendered anything, waiting for the provider to\n * adopt them (AGL-3660).\n *\n * The provider builds its services in its first render, and on the console\n * that render comes late: after every chunk of the route has executed, after\n * hydration, and after the `NoSsr` boundary above it has committed its\n * fallback and rendered again. Auth's persistence read and `accounts:lookup`,\n * and App Check's reCAPTCHA chain (script, script, iframe, token exchange),\n * which Auth AND Firestore requests both wait on whenever the cached App Check\n * token has expired, only started then. None of it depends on React, so a\n * surface that knows its config at module scope can start it there.\n *\n * Taken once, by the first provider that renders with the same app name and\n * persistence class; any later mount boots exactly as it always did. A\n * mismatched key is never adopted, so a preboot cannot hand a provider a\n * different persistence class than the one it was given.\n */\nconst prebooted = new Map<string, FirebaseCoreServices>()\nconst prebootKey = (appName: string, authPersistence: AuthPersistenceClass) =>\n `${appName}|${authPersistence}`\n\n/**\n * Start Firebase for this document now rather than at the provider's first\n * render. Browser only, once per app name and persistence class, and it never\n * throws: a failure here leaves the provider to boot as it always did.\n *\n * The Analytics consent gate is NOT read here (the provider reads it), so the\n * gate's module-scope registration does not have to precede this call.\n */\nexport function prebootFirebaseServices(options: {\n firebaseConfig: FirebaseOptions\n appName: string\n authPersistence?: AuthPersistenceClass\n}): void {\n if (typeof window === 'undefined') return\n const { firebaseConfig, appName, authPersistence = 'durable' } = options\n const key = prebootKey(appName, authPersistence)\n if (prebooted.has(key)) return\n try {\n prebooted.set(key, bootFirebaseCore(firebaseConfig, appName, authPersistence))\n } catch (error) {\n console.error(error)\n }\n}\n\nfunction takePrebootedFirebaseCore(\n appName: string,\n authPersistence: AuthPersistenceClass,\n): FirebaseCoreServices | undefined {\n const key = prebootKey(appName, authPersistence)\n const core = prebooted.get(key)\n if (core) prebooted.delete(key)\n return core\n}\n\nexport function FirebaseServicesProvider(props: FirebaseServicesProviderProps) {\n const { firebaseConfig, appName, authPersistence = 'durable', children } = props\n const servicesRef = useRef<FirebaseServices | undefined>(undefined)\n\n if (!servicesRef.current) {\n const core =\n takePrebootedFirebaseCore(appName, authPersistence) ??\n bootFirebaseCore(firebaseConfig, appName, authPersistence)\n // `initializeAnalytics`, not `getAnalytics`, for exactly one reason: it is\n // the only form that can pass `config`, and `send_page_view: false` is the\n // only way to stop the SDK's own startup `page_view` (AGL-1643).\n //\n // Booting Analytics issues `gtag('config', <id>, configProperties)`, and\n // the vendored SDK's own comment on that line reads \"This will trigger a\n // page_view event unless 'send_page_view' is set to false in\n // configProperties\". `getAnalytics(app)` passes no config at all, so the\n // key was never there and the hit always fired — on top of the manual\n // `page_view` the console's layout sends on mount. Two hits, one page.\n //\n // The SDK's is the one suppressed rather than the layout's, and the\n // direction is the whole decision: the SDK fires ONCE per document load,\n // while the layout fires on mount AND on every `usePathname` change. In a\n // client-routed app the layout's is a superset — killing it instead would\n // have left every in-app navigation unreported, halving console pageviews\n // rather than correcting them, and the reports would have looked fine.\n //\n // Nothing else about attribution moves: the surviving hit is sent from the\n // same document at mount, so `document.referrer` — which gtag resolves for\n // `page_referrer` itself, and which is what carries marketing traffic\n // source into the session — is still the external referrer at that moment.\n //\n // Re-entry is safe *only* while the options match. `initializeAnalytics`\n // returns the existing instance when they deep-equal the first call's and\n // throws `already-initialized` otherwise — and the options can be made to\n // differ by things no call site here controls (AGL-1979):\n //\n // * anything that touches the analytics provider WITHOUT options first\n // initializes it with `{}`. `getAnalytics()` is the obvious one, but\n // `@firebase/remote-config` reaches the same code path on its own —\n // `addExperimentToAnalytics` calls `analyticsProvider.getImmediate({\n // optional: true })`, and `optional` only suppresses the throw, it\n // does not stop the initialization.\n // * in dev, a module instance carrying a PREVIOUS version of these\n // options (this config changed under AGL-1857) surviving a Fast\n // Refresh alongside the new one.\n //\n // Once that happens the conflict is permanent for the document: every\n // later call throws, so without the fallback below `analytics` stayed\n // undefined forever and the next consumer — `logEvent(analytics, …)` —\n // read `.app` off it and threw a TypeError two frames away, which is how\n // this surfaced (top Cloud Error Reporting group, and reproduced live on\n // app.aglyn.com).\n //\n // `getAnalytics(app)` is the remedy the SDK's own error message names. It\n // cannot cost us the config on a healthy document: it only runs after\n // `initializeAnalytics` threw, and on a first init that throw can only be\n // `no-app-id` / `no-api-key` / `already-exists`, none of which\n // `getAnalytics` can rescue either — it re-throws and we fall through to\n // an undefined instance, which every consumer must now tolerate.\n // `content_group: 'console'` (AGL-1857) rides the same config: it is the\n // first-class GA4 axis that separates console traffic from `marketing`\n // (stamped by the tenant runtime on aglyn.com's own tag) and `docs`\n // (stamped by the Docusaurus head snippet) in standard reports, without\n // reaching for the Hostname dimension. The console is this provider's\n // only consumer, verified by grep — the tenant runtime builds its tag in\n // `site-analytics.tsx` and never passes through here.\n //\n // AND NOT AT ALL outside a real production deployment (AGL-2067). This\n // provider used to boot Analytics unconditionally, while\n // `apps/console/.env.development.local` points at the PRODUCTION\n // measurement id — so every `next dev` session and every Vercel preview\n // build produced genuine `session_start` / `first_visit` / `page_view`\n // hits in the live property. Not a theory: the archived Marketing\n // property's entire year-to-date history is 30 views / 6 users, ~24 of\n // them `/signin` on preview urls (docs/ANALYTICS.md).\n //\n // Not initialized rather than initialized-and-suppressed, because a\n // resident tag reports on its own — AGL-1608 is the same lesson from the\n // consent side. `useAnalytics()` returning undefined is already a\n // supported state everywhere (AGL-1979), which is what makes this a\n // two-line gate instead of an audit.\n //\n // `analyticsMayEmit` leans LOUD on an unknown deployment: a self-hosted\n // build points at the operator's own Firebase project and their own GA\n // property, and silencing a customer's analytics to protect ours is the\n // worse failure. See the module for the escape hatch.\n //\n // AND NOT while a registered consent gate says no (AGL-1498 posture,\n // applied to Aglyn's own console). `analyticsForConsentState` is where\n // both conditions and the boot itself now live, because the boot has to\n // be repeatable: a visitor whose region has not resolved yet is undecided\n // at this instant and may be granted a moment later, and a visitor who\n // withdraws mid-session has to lose the instance without the page\n // reloading. See the effect below.\n //\n // SCOPE. This is the console's tag and only the console's:\n // `CONSOLE_ANALYTICS_OPTIONS`, `content_group: 'console'`, and this\n // provider's analytics branch has no other consumer (the tenant runtime\n // builds its tag in `site-analytics.tsx` and never passes through here).\n // Customer sites keep the AGL-1498 gate and the host's own\n // `consent.mode`; nothing here reaches them.\n const initialAnalytics = analyticsForConsentState(core.app)\n\n servicesRef.current = {\n ...core,\n analytics: initialAnalytics,\n authPersistence,\n }\n }\n\n const services = servicesRef.current\n\n // The consent gate's other half: the verdict can CHANGE within one document,\n // in both directions, and everything else in this provider is built exactly\n // once by design.\n //\n // Deferred grant. A visitor with no stored record is undecided at the\n // instant this provider first renders, because resolving them needs the\n // region endpoint and that is a network call. Outside the prior-consent\n // regions the answer is implied consent, and it lands a few hundred\n // milliseconds later — same document, so `document.referrer` is still the\n // external referrer and the pageview's attribution is unaffected. Without\n // this, \"the gate is synchronous\" would have quietly meant \"the rest of the\n // world is never measured on a first visit\".\n //\n // Withdrawal. Dropping the instance unmounts every binding hung off it,\n // which is what unregisters the analytics transport — after that\n // `deliver()` falls through to `window.gtag`, and the shared consent writer\n // has already set `ga-disable-<id>` and sent a denied `consent update` on\n // that exact tag (AGL-1608). The resident tag cannot be unloaded; it can be\n // made to send nothing, and both halves are needed because either alone\n // leaks — enhanced measurement fires with no call site at all.\n const [analytics, setAnalytics] = useState<Analytics | undefined>(\n () => services.analytics,\n )\n useEffect(() => {\n const sync = () => setAnalytics(analyticsForConsentState(services.app))\n // Once on mount as well as on the event: the priming pass can resolve a\n // visitor between this provider's render and this effect, and the event it\n // dispatched then had no listener yet.\n sync()\n window.addEventListener(VISITOR_CONSENT_CHANGED_EVENT, sync)\n return () =>\n window.removeEventListener(VISITOR_CONSENT_CHANGED_EVENT, sync)\n }, [services])\n\n // Identity changes only when the tag comes or goes, so a consent-stable\n // document re-renders nothing.\n const value = useMemo(\n () =>\n services.analytics === analytics ? services : { ...services, analytics },\n [services, analytics],\n )\n\n return (\n <FirebaseServicesContext.Provider value={value}>\n {children}\n </FirebaseServicesContext.Provider>\n )\n}\nFirebaseServicesProvider.displayName = 'FirebaseServicesProvider'\n\nfunction useFirebaseServices(): FirebaseServices {\n const services = useContext(FirebaseServicesContext)\n if (!services) {\n throw new Error(\n 'Firebase hooks must be used within a <FirebaseServicesProvider>',\n )\n }\n return services\n}\n\nexport function useFirebaseApp(): FirebaseApp {\n return useFirebaseServices().app\n}\nexport function useFirestore(): Firestore {\n return useFirebaseServices().firestore\n}\nexport function useAuth(): Auth {\n return useFirebaseServices().auth\n}\n/**\n * The persistence class the provider's `Auth` was created with (AGL-1379).\n *\n * Read it — never re-derive it — when building a **second** Firebase app on\n * the same origin, so the second instance cannot end up more persistent than\n * the first. `apps/console/hooks/use-presence.ts` is the existing case.\n */\nexport function useAuthPersistence(): AuthPersistenceClass {\n return useFirebaseServices().authPersistence\n}\nexport function useAnalytics(): Analytics {\n return useFirebaseServices().analytics\n}\nexport function useRemoteConfig(): RemoteConfig {\n return useFirebaseServices().remoteConfig\n}\n\n/**\n * Replaces reactfire's `useUser()`. Reactfire subscribes via\n * `onIdTokenChanged` rather than `onAuthStateChanged` so fields like\n * `emailVerified` stay live across token refresh (e.g. after\n * `user.reload()` post-email-verification), not just sign-in/out — matched\n * here for behavioral parity.\n *\n * THREE states, not two (AGL-1261): `undefined` = auth has not resolved yet,\n * `null` = resolved, signed OUT, a `User` = resolved, signed in. This used to\n * collapse the emitted `null` into `undefined`, which made \"signed out\" and\n * \"still loading\" the same value — a state that, for a signed-out visitor,\n * never left \"loading\". `useSessionCookie` gates its whole body on\n * `user === undefined`, so its silent restore from the shared `__session`\n * cookie could never run: the cookie exchange returned a valid custom token\n * and nothing ever asked for it.\n */\nexport function useUser(): { data: User | null | undefined } {\n const auth = useAuth()\n const [user, setUser] = useState<User | null | undefined>(\n auth.currentUser ?? undefined,\n )\n\n useEffect(() => {\n return onIdTokenChanged(auth, (nextUser) => {\n // `?? null`, never `?? undefined` — see the note above.\n setUser(nextUser ?? null)\n })\n }, [auth])\n\n return { data: user }\n}\n\nexport interface SigninCheckResult {\n signedIn: boolean\n user: User | null\n}\n\n/** Replaces reactfire's `useSigninCheck()` — same `onIdTokenChanged` basis as `useUser()`. */\nexport function useSigninCheck(): {\n status: 'loading' | 'success' | 'error'\n data: SigninCheckResult | undefined\n error: Error | undefined\n} {\n const auth = useAuth()\n const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')\n const [data, setData] = useState<SigninCheckResult | undefined>(undefined)\n const [error, setError] = useState<Error | undefined>(undefined)\n\n useEffect(() => {\n return onIdTokenChanged(\n auth,\n (user) => {\n setStatus('success')\n setError(undefined)\n setData({ signedIn: !!user, user: user ?? null })\n },\n (err) => {\n setStatus('error')\n setError(err)\n },\n )\n }, [auth])\n\n return { status, data, error }\n}\n"],"names":["getApps","initializeApp","getAnalytics","getAnalyticsInstance","initializeAnalytics","initializeAnalyticsInstance","initializeAppCheck","ReCaptchaV3Provider","analyticsMayEmit","pushPlatformConsentDefault","VISITOR_CONSENT_CHANGED_EVENT","connectAuthEmulator","onIdTokenChanged","connectFirestoreEmulator","getFirestore","initializeFirestore","getRemoteConfig","getRemoteConfigInstance","createContext","useContext","useEffect","useMemo","useRef","useState","FIREBASE_AUTH_EMULATOR_ENABLED","FIREBASE_FIRESTORE_EMULATOR_ENABLED","APP_CHECK_KEY_MISSING_MESSAGE","appCheckSiteKey","authEmulatorUrl","firestoreEmulatorHost","createAuthInstance","firestoreCacheClassFor","localCacheFor","pruneSharedClientStateFor","markMultiTabFirestore","firestorePersistencePrefix","startFirestoreTabHandoff","FirebaseServicesContext","undefined","firestoreInitialized","Set","connectedAuth","isAutomatedChromeSession","navigator","webdriver","CONSOLE_ANALYTICS_OPTIONS","config","send_page_view","content_group","analyticsConsentGate","setAnalyticsConsentGate","gate","analyticsConsentAllows","analyticsByApp","WeakMap","bootAnalytics","app","existing","get","analytics","window","error","console","fallbackError","set","analyticsForConsentState","bootFirebaseCore","firebaseConfig","appName","authPersistence","find","name","auth","firestoreCacheClass","has","experimentalForceLongPolling","localCache","emulator","host","port","options","projectId","prefix","add","firestore","siteKey","warn","provider","isTokenAutoRefreshEnabled","remoteConfig","settings","minimumFetchIntervalMillis","process","env","NODE_ENV","prebooted","Map","prebootKey","prebootFirebaseServices","key","takePrebootedFirebaseCore","core","delete","FirebaseServicesProvider","props","children","servicesRef","current","initialAnalytics","services","setAnalytics","sync","addEventListener","removeEventListener","value","Provider","displayName","useFirebaseServices","Error","useFirebaseApp","useFirestore","useAuth","useAuthPersistence","useAnalytics","useRemoteConfig","useUser","user","setUser","currentUser","nextUser","data","useSigninCheck","status","setStatus","setData","setError","signedIn","err"],"mappings":"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"}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type Firestore } from 'firebase/firestore';
|
|
18
|
+
/**
|
|
19
|
+
* A NEWLY OPENED, VISIBLE TAB TAKES THE PRIMARY LEASE FROM A HIDDEN ONE
|
|
20
|
+
* (AGL-3660).
|
|
21
|
+
*
|
|
22
|
+
* ## What the SDK does on its own
|
|
23
|
+
*
|
|
24
|
+
* With `persistentMultipleTabManager`, exactly one tab per origin — the
|
|
25
|
+
* holder of the `owner` row in `firestore/<app>/<project>/main` — runs the
|
|
26
|
+
* network. Every other tab sends its queries to that tab through
|
|
27
|
+
* `localStorage` and reads the answers back out of IndexedDB. Read against
|
|
28
|
+
* `@firebase/firestore` 4.17.2 (`IndexedDbPersistence`):
|
|
29
|
+
*
|
|
30
|
+
* - **A valid lease is never handed over.** `canActAsPrimary` returns `true`
|
|
31
|
+
* for the holder whenever its own lease is under 5 s old
|
|
32
|
+
* (`MAX_PRIMARY_ELIGIBLE_AGE_MS`) and its network is enabled, whatever its
|
|
33
|
+
* visibility. The "prefer the foreground tab" rule only applies once the
|
|
34
|
+
* lease has lapsed. A console tab the user left in the background therefore
|
|
35
|
+
* keeps serving the tab they are looking at, for as long as its 4 s renewal
|
|
36
|
+
* timer keeps firing.
|
|
37
|
+
* - **A background holder is a slow server.** Its renewal timer is throttled
|
|
38
|
+
* (production lease rows, 2026-10-08: renewed every ~4.9 s, against a 5 s
|
|
39
|
+
* lapse), and every query the new tab issues waits on that tab's event loop.
|
|
40
|
+
* The workspace read on a fresh `/aglyn-org/media` tab came back only once
|
|
41
|
+
* the new tab had won the lease on a lapse, its own Listen channel opening
|
|
42
|
+
* ~4.7 s in (PR #1313).
|
|
43
|
+
* - **The new tab's first record says it is hidden.** `start()` runs its
|
|
44
|
+
* first election (`updateClientMetadataAndTryBecomePrimary`) before
|
|
45
|
+
* `attachVisibilityHandler()` has read `document.visibilityState`, so the
|
|
46
|
+
* `clientMetadata` row it writes carries `inForeground: false` until the
|
|
47
|
+
* next 4 s tick. When the holder's lease does lapse in that window, the
|
|
48
|
+
* holder finds no better candidate and takes it straight back.
|
|
49
|
+
*
|
|
50
|
+
* ## What this does
|
|
51
|
+
*
|
|
52
|
+
* Once, at boot, in a tab that is visible and online:
|
|
53
|
+
*
|
|
54
|
+
* 1. Snapshot the client ids already registered, synchronously, in the same
|
|
55
|
+
* task as `initializeFirestore` — before anything can start this tab's
|
|
56
|
+
* client. They come from the SDK's `firestore_clients_<prefix>_<id>` keys
|
|
57
|
+
* in `localStorage`, which a tab writes once its persistence has started.
|
|
58
|
+
* 2. Start this tab's Firestore (`enableNetwork` — a no-op on a client whose
|
|
59
|
+
* network is on — resolves once persistence has started and the tab has
|
|
60
|
+
* run its first election). The one id that was not there before is this
|
|
61
|
+
* tab.
|
|
62
|
+
* 3. In ONE IndexedDB transaction over `owner`, `clientMetadata` and
|
|
63
|
+
* `mutations`, decide with {@link decideLeaseClaim}. When the holder is
|
|
64
|
+
* another tab that has recorded itself as hidden and no write is pending,
|
|
65
|
+
* write this tab's row as foreground and the lease in this tab's name.
|
|
66
|
+
* 4. Cycle this tab's network (`disableNetwork` then `enableNetwork`): each
|
|
67
|
+
* step runs the SDK's own election at once, and the second finds a valid
|
|
68
|
+
* lease that is this tab's and becomes primary.
|
|
69
|
+
*
|
|
70
|
+
* ## Why this is safe
|
|
71
|
+
*
|
|
72
|
+
* - **It is the SDK's own handoff.** A holder that finds the lease in another
|
|
73
|
+
* tab's name is exactly a holder whose lease lapsed: every
|
|
74
|
+
* `readwrite-primary` transaction re-verifies the lease inside the
|
|
75
|
+
* transaction (`verifyPrimaryLease`), fails, flips the tab to secondary and
|
|
76
|
+
* throws the `failed-precondition` its sync engine already swallows
|
|
77
|
+
* (`ignoreIfPrimaryLeaseLoss`). IndexedDB serialises our transaction against
|
|
78
|
+
* the SDK's (which span every store), so no election interleaves with it.
|
|
79
|
+
* - **The old holder cannot take it back.** The lease is valid and not its
|
|
80
|
+
* own, so its `canActAsPrimary` is `false`. Should this tab fail to renew,
|
|
81
|
+
* the lease lapses in 5 s and the corrected `inForeground: true` row makes
|
|
82
|
+
* this tab the preferred candidate, not the hidden one.
|
|
83
|
+
* - **Never with a write in flight.** The holder sends pending mutations; a
|
|
84
|
+
* new primary resends every unacknowledged batch. Any row in `mutations`
|
|
85
|
+
* skips the claim, so a write is never sent twice by two tabs.
|
|
86
|
+
* - **Never from a visible holder.** A tab visible in another window serves
|
|
87
|
+
* quickly (measured in production: ~390 ms from a query target written to
|
|
88
|
+
* it being `current`), and taking its lease would only move the work.
|
|
89
|
+
* - **The cycle is invisible in a secondary.** The sync engine ignores its
|
|
90
|
+
* own remote store's online state while secondary
|
|
91
|
+
* (`syncEngineApplyOnlineStateChange`), so no listener sees an offline
|
|
92
|
+
* flicker. If this tab somehow became primary in between, the cycle is the
|
|
93
|
+
* same one `firestore-stall-recovery.ts` runs.
|
|
94
|
+
* - **Every failure is the old behaviour.** Anything unexpected — no
|
|
95
|
+
* database, the store locked (the AGL-3428 wedge), two new tabs at once, a
|
|
96
|
+
* holder that is visible — skips the claim, and the SDK's own election runs
|
|
97
|
+
* as it always has.
|
|
98
|
+
*
|
|
99
|
+
* Not chosen: `persistentSingleTabManager` (rejected in AGL-3373/3428, and
|
|
100
|
+
* `forceOwnership` there fails every other tab with
|
|
101
|
+
* `PRIMARY_LEASE_EXCLUSIVE_ERROR_MSG`); `getDocFromServer` (a secondary routes
|
|
102
|
+
* it through the holder like any listen); a per-tab memory cache for the first
|
|
103
|
+
* read (the cache is fixed at `initializeFirestore`); a synthetic
|
|
104
|
+
* `visibilitychange` to rerun the election (also fires the console's own
|
|
105
|
+
* twenty-odd visibility listeners).
|
|
106
|
+
*/
|
|
107
|
+
/** The SDK's lease lifetime (`MAX_PRIMARY_ELIGIBLE_AGE_MS`). */
|
|
108
|
+
export declare const PRIMARY_LEASE_MAX_AGE_MS = 5000;
|
|
109
|
+
/**
|
|
110
|
+
* How long the claim transaction may wait to start. The SDK's own take
|
|
111
|
+
* milliseconds; one that cannot start is behind the AGL-3428 wedge, which
|
|
112
|
+
* `firestore-multitab-wedge.ts` handles.
|
|
113
|
+
*/
|
|
114
|
+
export declare const CLAIM_TIMEOUT_MS = 1500;
|
|
115
|
+
/** `DbPrimaryClient`. */
|
|
116
|
+
export interface LeaseRow {
|
|
117
|
+
ownerId: string;
|
|
118
|
+
allowTabSynchronization: boolean;
|
|
119
|
+
leaseTimestampMs: number;
|
|
120
|
+
}
|
|
121
|
+
/** `DbClientMetadata`. */
|
|
122
|
+
export interface ClientRow {
|
|
123
|
+
clientId: string;
|
|
124
|
+
updateTimeMs: number;
|
|
125
|
+
networkEnabled: boolean;
|
|
126
|
+
inForeground: boolean;
|
|
127
|
+
}
|
|
128
|
+
/** Why the claim was not made. */
|
|
129
|
+
export type LeaseClaimSkip = 'unidentified' | 'vacant' | 'already-primary' | 'exclusive-holder' | 'holder-unknown' | 'holder-visible' | 'pending-writes';
|
|
130
|
+
export type LeaseClaimDecision = {
|
|
131
|
+
claim: true;
|
|
132
|
+
} | {
|
|
133
|
+
claim: false;
|
|
134
|
+
reason: LeaseClaimSkip;
|
|
135
|
+
};
|
|
136
|
+
/**
|
|
137
|
+
* Whether this tab (`self`) should take the lease `owner` holds. Pure: the
|
|
138
|
+
* caller reads every input inside the one transaction that writes the result.
|
|
139
|
+
*
|
|
140
|
+
* A vacant or lapsed-and-vacant lease is left to the SDK, whose next election
|
|
141
|
+
* takes it; only a lease another tab holds is ever claimed, and only from a
|
|
142
|
+
* holder whose own row says it is in the background.
|
|
143
|
+
*/
|
|
144
|
+
export declare function decideLeaseClaim(input: {
|
|
145
|
+
owner: LeaseRow | undefined;
|
|
146
|
+
holder: ClientRow | undefined;
|
|
147
|
+
self: ClientRow | undefined;
|
|
148
|
+
pendingWrites: number;
|
|
149
|
+
}): LeaseClaimDecision;
|
|
150
|
+
/** What the boot handoff did. */
|
|
151
|
+
export type TabHandoffOutcome = {
|
|
152
|
+
result: 'claimed';
|
|
153
|
+
fromOwnerId: string;
|
|
154
|
+
leaseAgeMs: number;
|
|
155
|
+
} | {
|
|
156
|
+
result: 'skipped';
|
|
157
|
+
reason: LeaseClaimSkip | 'hidden' | 'offline' | 'alone' | 'no-database' | 'locked' | 'error';
|
|
158
|
+
};
|
|
159
|
+
/** What one claim transaction found and did. */
|
|
160
|
+
export type ClaimTransactionResult = {
|
|
161
|
+
state: 'claimed';
|
|
162
|
+
fromOwnerId: string;
|
|
163
|
+
leaseAgeMs: number;
|
|
164
|
+
} | {
|
|
165
|
+
state: 'skipped';
|
|
166
|
+
reason: LeaseClaimSkip;
|
|
167
|
+
} | {
|
|
168
|
+
state: 'locked';
|
|
169
|
+
} | {
|
|
170
|
+
state: 'unavailable';
|
|
171
|
+
};
|
|
172
|
+
export interface TabHandoffDeps {
|
|
173
|
+
isVisible: () => boolean;
|
|
174
|
+
isOnline: () => boolean;
|
|
175
|
+
/**
|
|
176
|
+
* Client ids registered on this origin. Must be synchronous: the first call
|
|
177
|
+
* is the snapshot taken before this tab's client can start.
|
|
178
|
+
*/
|
|
179
|
+
readClientIds: () => ReadonlySet<string>;
|
|
180
|
+
/** Starts this tab's Firestore; resolves once its persistence has started. */
|
|
181
|
+
startClient: () => Promise<void>;
|
|
182
|
+
/** Runs the claim for `selfId` in one transaction. */
|
|
183
|
+
claim: (selfId: string | undefined) => Promise<ClaimTransactionResult>;
|
|
184
|
+
/** `disableNetwork` then `enableNetwork`: two elections, at once. */
|
|
185
|
+
cycleNetwork: () => Promise<void>;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The boot handoff, with its browser parts injected. Never rejects.
|
|
189
|
+
*/
|
|
190
|
+
export declare function runTabHandoff(deps: TabHandoffDeps): Promise<TabHandoffOutcome>;
|
|
191
|
+
/**
|
|
192
|
+
* The client ids the SDK has registered under `prefix`
|
|
193
|
+
* (`firestore/<app>/<project>/`), from its `firestore_clients_<prefix>_<id>`
|
|
194
|
+
* keys. Synchronous, so a snapshot can be taken before this tab's client
|
|
195
|
+
* starts. A key whose tab has since closed is harmless here: only ids that
|
|
196
|
+
* APPEAR between two snapshots are read as this tab.
|
|
197
|
+
*/
|
|
198
|
+
export declare function readSharedClientIds(storage: Pick<Storage, 'length' | 'key'> | undefined, prefix: string): ReadonlySet<string>;
|
|
199
|
+
/**
|
|
200
|
+
* Reads the lease, both client rows and the pending-write count, decides, and
|
|
201
|
+
* writes the claim — all in one `readwrite` transaction, so no election runs
|
|
202
|
+
* between the read and the write. Aborted when it cannot start within
|
|
203
|
+
* `timeoutMs`.
|
|
204
|
+
*/
|
|
205
|
+
export declare function claimPrimaryLease(factory: IDBFactory | undefined, databaseName: string, selfId: string | undefined, { timeoutMs, now, }?: {
|
|
206
|
+
timeoutMs?: number;
|
|
207
|
+
now?: () => number;
|
|
208
|
+
}): Promise<ClaimTransactionResult>;
|
|
209
|
+
/**
|
|
210
|
+
* Runs the handoff for a Firestore that `initializeFirestore` has just
|
|
211
|
+
* configured with the durable multi-tab cache under `prefix`
|
|
212
|
+
* (`firestorePersistencePrefix`). Call it in the same task as
|
|
213
|
+
* `initializeFirestore`, before any read: its first step is the synchronous
|
|
214
|
+
* snapshot of the clients that are not this tab. Fire and forget: it never
|
|
215
|
+
* rejects, and every failure leaves the SDK's own election in charge.
|
|
216
|
+
*/
|
|
217
|
+
export declare function startFirestoreTabHandoff(firestore: Firestore, prefix: string): Promise<TabHandoffOutcome>;
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { disableNetwork, enableNetwork } from "firebase/firestore";
|
|
18
|
+
/**
|
|
19
|
+
* A NEWLY OPENED, VISIBLE TAB TAKES THE PRIMARY LEASE FROM A HIDDEN ONE
|
|
20
|
+
* (AGL-3660).
|
|
21
|
+
*
|
|
22
|
+
* ## What the SDK does on its own
|
|
23
|
+
*
|
|
24
|
+
* With `persistentMultipleTabManager`, exactly one tab per origin — the
|
|
25
|
+
* holder of the `owner` row in `firestore/<app>/<project>/main` — runs the
|
|
26
|
+
* network. Every other tab sends its queries to that tab through
|
|
27
|
+
* `localStorage` and reads the answers back out of IndexedDB. Read against
|
|
28
|
+
* `@firebase/firestore` 4.17.2 (`IndexedDbPersistence`):
|
|
29
|
+
*
|
|
30
|
+
* - **A valid lease is never handed over.** `canActAsPrimary` returns `true`
|
|
31
|
+
* for the holder whenever its own lease is under 5 s old
|
|
32
|
+
* (`MAX_PRIMARY_ELIGIBLE_AGE_MS`) and its network is enabled, whatever its
|
|
33
|
+
* visibility. The "prefer the foreground tab" rule only applies once the
|
|
34
|
+
* lease has lapsed. A console tab the user left in the background therefore
|
|
35
|
+
* keeps serving the tab they are looking at, for as long as its 4 s renewal
|
|
36
|
+
* timer keeps firing.
|
|
37
|
+
* - **A background holder is a slow server.** Its renewal timer is throttled
|
|
38
|
+
* (production lease rows, 2026-10-08: renewed every ~4.9 s, against a 5 s
|
|
39
|
+
* lapse), and every query the new tab issues waits on that tab's event loop.
|
|
40
|
+
* The workspace read on a fresh `/aglyn-org/media` tab came back only once
|
|
41
|
+
* the new tab had won the lease on a lapse, its own Listen channel opening
|
|
42
|
+
* ~4.7 s in (PR #1313).
|
|
43
|
+
* - **The new tab's first record says it is hidden.** `start()` runs its
|
|
44
|
+
* first election (`updateClientMetadataAndTryBecomePrimary`) before
|
|
45
|
+
* `attachVisibilityHandler()` has read `document.visibilityState`, so the
|
|
46
|
+
* `clientMetadata` row it writes carries `inForeground: false` until the
|
|
47
|
+
* next 4 s tick. When the holder's lease does lapse in that window, the
|
|
48
|
+
* holder finds no better candidate and takes it straight back.
|
|
49
|
+
*
|
|
50
|
+
* ## What this does
|
|
51
|
+
*
|
|
52
|
+
* Once, at boot, in a tab that is visible and online:
|
|
53
|
+
*
|
|
54
|
+
* 1. Snapshot the client ids already registered, synchronously, in the same
|
|
55
|
+
* task as `initializeFirestore` — before anything can start this tab's
|
|
56
|
+
* client. They come from the SDK's `firestore_clients_<prefix>_<id>` keys
|
|
57
|
+
* in `localStorage`, which a tab writes once its persistence has started.
|
|
58
|
+
* 2. Start this tab's Firestore (`enableNetwork` — a no-op on a client whose
|
|
59
|
+
* network is on — resolves once persistence has started and the tab has
|
|
60
|
+
* run its first election). The one id that was not there before is this
|
|
61
|
+
* tab.
|
|
62
|
+
* 3. In ONE IndexedDB transaction over `owner`, `clientMetadata` and
|
|
63
|
+
* `mutations`, decide with {@link decideLeaseClaim}. When the holder is
|
|
64
|
+
* another tab that has recorded itself as hidden and no write is pending,
|
|
65
|
+
* write this tab's row as foreground and the lease in this tab's name.
|
|
66
|
+
* 4. Cycle this tab's network (`disableNetwork` then `enableNetwork`): each
|
|
67
|
+
* step runs the SDK's own election at once, and the second finds a valid
|
|
68
|
+
* lease that is this tab's and becomes primary.
|
|
69
|
+
*
|
|
70
|
+
* ## Why this is safe
|
|
71
|
+
*
|
|
72
|
+
* - **It is the SDK's own handoff.** A holder that finds the lease in another
|
|
73
|
+
* tab's name is exactly a holder whose lease lapsed: every
|
|
74
|
+
* `readwrite-primary` transaction re-verifies the lease inside the
|
|
75
|
+
* transaction (`verifyPrimaryLease`), fails, flips the tab to secondary and
|
|
76
|
+
* throws the `failed-precondition` its sync engine already swallows
|
|
77
|
+
* (`ignoreIfPrimaryLeaseLoss`). IndexedDB serialises our transaction against
|
|
78
|
+
* the SDK's (which span every store), so no election interleaves with it.
|
|
79
|
+
* - **The old holder cannot take it back.** The lease is valid and not its
|
|
80
|
+
* own, so its `canActAsPrimary` is `false`. Should this tab fail to renew,
|
|
81
|
+
* the lease lapses in 5 s and the corrected `inForeground: true` row makes
|
|
82
|
+
* this tab the preferred candidate, not the hidden one.
|
|
83
|
+
* - **Never with a write in flight.** The holder sends pending mutations; a
|
|
84
|
+
* new primary resends every unacknowledged batch. Any row in `mutations`
|
|
85
|
+
* skips the claim, so a write is never sent twice by two tabs.
|
|
86
|
+
* - **Never from a visible holder.** A tab visible in another window serves
|
|
87
|
+
* quickly (measured in production: ~390 ms from a query target written to
|
|
88
|
+
* it being `current`), and taking its lease would only move the work.
|
|
89
|
+
* - **The cycle is invisible in a secondary.** The sync engine ignores its
|
|
90
|
+
* own remote store's online state while secondary
|
|
91
|
+
* (`syncEngineApplyOnlineStateChange`), so no listener sees an offline
|
|
92
|
+
* flicker. If this tab somehow became primary in between, the cycle is the
|
|
93
|
+
* same one `firestore-stall-recovery.ts` runs.
|
|
94
|
+
* - **Every failure is the old behaviour.** Anything unexpected — no
|
|
95
|
+
* database, the store locked (the AGL-3428 wedge), two new tabs at once, a
|
|
96
|
+
* holder that is visible — skips the claim, and the SDK's own election runs
|
|
97
|
+
* as it always has.
|
|
98
|
+
*
|
|
99
|
+
* Not chosen: `persistentSingleTabManager` (rejected in AGL-3373/3428, and
|
|
100
|
+
* `forceOwnership` there fails every other tab with
|
|
101
|
+
* `PRIMARY_LEASE_EXCLUSIVE_ERROR_MSG`); `getDocFromServer` (a secondary routes
|
|
102
|
+
* it through the holder like any listen); a per-tab memory cache for the first
|
|
103
|
+
* read (the cache is fixed at `initializeFirestore`); a synthetic
|
|
104
|
+
* `visibilitychange` to rerun the election (also fires the console's own
|
|
105
|
+
* twenty-odd visibility listeners).
|
|
106
|
+
*/ /** The SDK's lease lifetime (`MAX_PRIMARY_ELIGIBLE_AGE_MS`). */ export const PRIMARY_LEASE_MAX_AGE_MS = 5000;
|
|
107
|
+
/**
|
|
108
|
+
* How long the claim transaction may wait to start. The SDK's own take
|
|
109
|
+
* milliseconds; one that cannot start is behind the AGL-3428 wedge, which
|
|
110
|
+
* `firestore-multitab-wedge.ts` handles.
|
|
111
|
+
*/ export const CLAIM_TIMEOUT_MS = 1500;
|
|
112
|
+
/** The SDK's stores and the lease row's key (`indexeddb_sentinels.ts`). */ const OWNER_STORE = 'owner';
|
|
113
|
+
const OWNER_KEY = 'owner';
|
|
114
|
+
const CLIENT_METADATA_STORE = 'clientMetadata';
|
|
115
|
+
const MUTATIONS_STORE = 'mutations';
|
|
116
|
+
/**
|
|
117
|
+
* Whether this tab (`self`) should take the lease `owner` holds. Pure: the
|
|
118
|
+
* caller reads every input inside the one transaction that writes the result.
|
|
119
|
+
*
|
|
120
|
+
* A vacant or lapsed-and-vacant lease is left to the SDK, whose next election
|
|
121
|
+
* takes it; only a lease another tab holds is ever claimed, and only from a
|
|
122
|
+
* holder whose own row says it is in the background.
|
|
123
|
+
*/ export function decideLeaseClaim(input) {
|
|
124
|
+
const { owner, holder, self, pendingWrites } = input;
|
|
125
|
+
if (!self) return {
|
|
126
|
+
claim: false,
|
|
127
|
+
reason: 'unidentified'
|
|
128
|
+
};
|
|
129
|
+
if (!owner) return {
|
|
130
|
+
claim: false,
|
|
131
|
+
reason: 'vacant'
|
|
132
|
+
};
|
|
133
|
+
if (owner.ownerId === self.clientId) return {
|
|
134
|
+
claim: false,
|
|
135
|
+
reason: 'already-primary'
|
|
136
|
+
};
|
|
137
|
+
// A single-tab holder never shares; `persistentMultipleTabManager` never
|
|
138
|
+
// writes `false`, so this is another configuration on the origin.
|
|
139
|
+
if (!owner.allowTabSynchronization) return {
|
|
140
|
+
claim: false,
|
|
141
|
+
reason: 'exclusive-holder'
|
|
142
|
+
};
|
|
143
|
+
if (pendingWrites > 0) return {
|
|
144
|
+
claim: false,
|
|
145
|
+
reason: 'pending-writes'
|
|
146
|
+
};
|
|
147
|
+
if (!holder) return {
|
|
148
|
+
claim: false,
|
|
149
|
+
reason: 'holder-unknown'
|
|
150
|
+
};
|
|
151
|
+
if (holder.inForeground) return {
|
|
152
|
+
claim: false,
|
|
153
|
+
reason: 'holder-visible'
|
|
154
|
+
};
|
|
155
|
+
return {
|
|
156
|
+
claim: true
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The boot handoff, with its browser parts injected. Never rejects.
|
|
161
|
+
*/ export async function runTabHandoff(deps) {
|
|
162
|
+
try {
|
|
163
|
+
if (!deps.isVisible()) return {
|
|
164
|
+
result: 'skipped',
|
|
165
|
+
reason: 'hidden'
|
|
166
|
+
};
|
|
167
|
+
if (!deps.isOnline()) return {
|
|
168
|
+
result: 'skipped',
|
|
169
|
+
reason: 'offline'
|
|
170
|
+
};
|
|
171
|
+
// Before this tab's Firestore starts, or this tab would be in it.
|
|
172
|
+
const before = deps.readClientIds();
|
|
173
|
+
// No other tab: this one wins the lease at its first election.
|
|
174
|
+
if (before.size === 0) return {
|
|
175
|
+
result: 'skipped',
|
|
176
|
+
reason: 'alone'
|
|
177
|
+
};
|
|
178
|
+
await deps.startClient();
|
|
179
|
+
// The user may have switched away while the client started.
|
|
180
|
+
if (!deps.isVisible()) return {
|
|
181
|
+
result: 'skipped',
|
|
182
|
+
reason: 'hidden'
|
|
183
|
+
};
|
|
184
|
+
const added = [
|
|
185
|
+
...deps.readClientIds()
|
|
186
|
+
].filter((id)=>!before.has(id));
|
|
187
|
+
// Exactly one new client is this tab. None means this tab's Firestore
|
|
188
|
+
// started before the snapshot; two means another tab started alongside.
|
|
189
|
+
// Either way this tab cannot be told apart, so nothing is claimed.
|
|
190
|
+
if (added.length !== 1) return {
|
|
191
|
+
result: 'skipped',
|
|
192
|
+
reason: 'unidentified'
|
|
193
|
+
};
|
|
194
|
+
const selfId = added[0];
|
|
195
|
+
const outcome = await deps.claim(selfId);
|
|
196
|
+
if (outcome.state === 'locked') return {
|
|
197
|
+
result: 'skipped',
|
|
198
|
+
reason: 'locked'
|
|
199
|
+
};
|
|
200
|
+
if (outcome.state === 'unavailable') return {
|
|
201
|
+
result: 'skipped',
|
|
202
|
+
reason: 'no-database'
|
|
203
|
+
};
|
|
204
|
+
if (outcome.state === 'skipped') return {
|
|
205
|
+
result: 'skipped',
|
|
206
|
+
reason: outcome.reason
|
|
207
|
+
};
|
|
208
|
+
await deps.cycleNetwork();
|
|
209
|
+
return {
|
|
210
|
+
result: 'claimed',
|
|
211
|
+
fromOwnerId: outcome.fromOwnerId,
|
|
212
|
+
leaseAgeMs: outcome.leaseAgeMs
|
|
213
|
+
};
|
|
214
|
+
} catch (unused) {
|
|
215
|
+
return {
|
|
216
|
+
result: 'skipped',
|
|
217
|
+
reason: 'error'
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Opens `databaseName` only if it already exists, at its current version —
|
|
223
|
+
* never creating it under the SDK (the rule `probePrimaryLease` follows) —
|
|
224
|
+
* and closes on `versionchange` so an SDK upgrade is never held up.
|
|
225
|
+
*/ async function openExisting(factory, databaseName) {
|
|
226
|
+
if (typeof factory.databases !== 'function') return undefined;
|
|
227
|
+
const databases = await factory.databases();
|
|
228
|
+
if (!databases.some((database)=>database.name === databaseName)) return undefined;
|
|
229
|
+
return new Promise((resolve)=>{
|
|
230
|
+
let request;
|
|
231
|
+
try {
|
|
232
|
+
request = factory.open(databaseName);
|
|
233
|
+
} catch (unused) {
|
|
234
|
+
resolve(undefined);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
request.onupgradeneeded = ()=>{
|
|
238
|
+
var _request_transaction;
|
|
239
|
+
return (_request_transaction = request.transaction) == null ? void 0 : _request_transaction.abort();
|
|
240
|
+
};
|
|
241
|
+
request.onerror = ()=>resolve(undefined);
|
|
242
|
+
request.onblocked = ()=>resolve(undefined);
|
|
243
|
+
request.onsuccess = ()=>{
|
|
244
|
+
const database = request.result;
|
|
245
|
+
database.onversionchange = ()=>database.close();
|
|
246
|
+
resolve(database);
|
|
247
|
+
};
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
const hasStores = (database, stores)=>stores.every((store)=>database.objectStoreNames.contains(store));
|
|
251
|
+
const escapeRegExp = (text)=>text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
252
|
+
/**
|
|
253
|
+
* The client ids the SDK has registered under `prefix`
|
|
254
|
+
* (`firestore/<app>/<project>/`), from its `firestore_clients_<prefix>_<id>`
|
|
255
|
+
* keys. Synchronous, so a snapshot can be taken before this tab's client
|
|
256
|
+
* starts. A key whose tab has since closed is harmless here: only ids that
|
|
257
|
+
* APPEAR between two snapshots are read as this tab.
|
|
258
|
+
*/ export function readSharedClientIds(storage, prefix) {
|
|
259
|
+
const ids = new Set();
|
|
260
|
+
if (!storage) return ids;
|
|
261
|
+
// The SDK's own key shape: a client id never contains `_`.
|
|
262
|
+
const clientKey = new RegExp(`^firestore_clients_${escapeRegExp(prefix)}_([^_]+)$`);
|
|
263
|
+
try {
|
|
264
|
+
for(let i = 0; i < storage.length; i++){
|
|
265
|
+
var _storage_key;
|
|
266
|
+
const match = clientKey.exec((_storage_key = storage.key(i)) != null ? _storage_key : '');
|
|
267
|
+
if (match) ids.add(match[1]);
|
|
268
|
+
}
|
|
269
|
+
} catch (unused) {
|
|
270
|
+
// Site data blocked: no ids, so the handoff stands down.
|
|
271
|
+
}
|
|
272
|
+
return ids;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Reads the lease, both client rows and the pending-write count, decides, and
|
|
276
|
+
* writes the claim — all in one `readwrite` transaction, so no election runs
|
|
277
|
+
* between the read and the write. Aborted when it cannot start within
|
|
278
|
+
* `timeoutMs`.
|
|
279
|
+
*/ export async function claimPrimaryLease(factory, databaseName, selfId, { timeoutMs = CLAIM_TIMEOUT_MS, now = Date.now } = {}) {
|
|
280
|
+
if (!factory) return {
|
|
281
|
+
state: 'unavailable'
|
|
282
|
+
};
|
|
283
|
+
const database = await openExisting(factory, databaseName).catch(()=>undefined);
|
|
284
|
+
if (!database) return {
|
|
285
|
+
state: 'unavailable'
|
|
286
|
+
};
|
|
287
|
+
const stores = [
|
|
288
|
+
OWNER_STORE,
|
|
289
|
+
CLIENT_METADATA_STORE,
|
|
290
|
+
MUTATIONS_STORE
|
|
291
|
+
];
|
|
292
|
+
if (!hasStores(database, stores)) {
|
|
293
|
+
database.close();
|
|
294
|
+
return {
|
|
295
|
+
state: 'unavailable'
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
return new Promise((resolve)=>{
|
|
299
|
+
let transaction;
|
|
300
|
+
try {
|
|
301
|
+
transaction = database.transaction(stores, 'readwrite');
|
|
302
|
+
} catch (unused) {
|
|
303
|
+
database.close();
|
|
304
|
+
resolve({
|
|
305
|
+
state: 'unavailable'
|
|
306
|
+
});
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
let settled = false;
|
|
310
|
+
let started = false;
|
|
311
|
+
let result = {
|
|
312
|
+
state: 'unavailable'
|
|
313
|
+
};
|
|
314
|
+
// Only a transaction that cannot START is aborted: once the lease read
|
|
315
|
+
// has landed, the decision and its writes take milliseconds.
|
|
316
|
+
const timer = setTimeout(()=>{
|
|
317
|
+
if (started) return;
|
|
318
|
+
try {
|
|
319
|
+
transaction.abort();
|
|
320
|
+
} catch (unused) {
|
|
321
|
+
// Already finished.
|
|
322
|
+
}
|
|
323
|
+
finish({
|
|
324
|
+
state: 'locked'
|
|
325
|
+
});
|
|
326
|
+
}, timeoutMs);
|
|
327
|
+
const finish = (value)=>{
|
|
328
|
+
if (settled) return;
|
|
329
|
+
settled = true;
|
|
330
|
+
clearTimeout(timer);
|
|
331
|
+
database.close();
|
|
332
|
+
resolve(value);
|
|
333
|
+
};
|
|
334
|
+
transaction.oncomplete = ()=>finish(result);
|
|
335
|
+
transaction.onabort = ()=>finish(started ? {
|
|
336
|
+
state: 'unavailable'
|
|
337
|
+
} : {
|
|
338
|
+
state: 'locked'
|
|
339
|
+
});
|
|
340
|
+
transaction.onerror = ()=>finish({
|
|
341
|
+
state: 'unavailable'
|
|
342
|
+
});
|
|
343
|
+
const owners = transaction.objectStore(OWNER_STORE);
|
|
344
|
+
const clients = transaction.objectStore(CLIENT_METADATA_STORE);
|
|
345
|
+
const ownerRequest = owners.get(OWNER_KEY);
|
|
346
|
+
ownerRequest.onsuccess = ()=>{
|
|
347
|
+
started = true;
|
|
348
|
+
const owner = ownerRequest.result;
|
|
349
|
+
const selfRequest = selfId === undefined ? undefined : clients.get(selfId);
|
|
350
|
+
const holderRequest = (owner == null ? void 0 : owner.ownerId) ? clients.get(owner.ownerId) : undefined;
|
|
351
|
+
const countRequest = transaction.objectStore(MUTATIONS_STORE).count();
|
|
352
|
+
countRequest.onsuccess = ()=>{
|
|
353
|
+
// Requests in one transaction complete in order: both gets landed.
|
|
354
|
+
const self = selfRequest == null ? void 0 : selfRequest.result;
|
|
355
|
+
const holder = holderRequest == null ? void 0 : holderRequest.result;
|
|
356
|
+
const decision = decideLeaseClaim({
|
|
357
|
+
owner,
|
|
358
|
+
holder,
|
|
359
|
+
self,
|
|
360
|
+
pendingWrites: countRequest.result
|
|
361
|
+
});
|
|
362
|
+
if ('reason' in decision) {
|
|
363
|
+
result = {
|
|
364
|
+
state: 'skipped',
|
|
365
|
+
reason: decision.reason
|
|
366
|
+
};
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const at = now();
|
|
370
|
+
// The row the SDK's first election should have written, then the
|
|
371
|
+
// lease in this tab's name — `acquireOrExtendPrimaryLease`'s shape.
|
|
372
|
+
clients.put(_extends({}, self, {
|
|
373
|
+
inForeground: true,
|
|
374
|
+
updateTimeMs: at
|
|
375
|
+
}));
|
|
376
|
+
owners.put({
|
|
377
|
+
ownerId: self.clientId,
|
|
378
|
+
allowTabSynchronization: true,
|
|
379
|
+
leaseTimestampMs: at
|
|
380
|
+
}, OWNER_KEY);
|
|
381
|
+
result = {
|
|
382
|
+
state: 'claimed',
|
|
383
|
+
fromOwnerId: owner.ownerId,
|
|
384
|
+
leaseAgeMs: at - owner.leaseTimestampMs
|
|
385
|
+
};
|
|
386
|
+
};
|
|
387
|
+
};
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
const browserIndexedDb = ()=>{
|
|
391
|
+
try {
|
|
392
|
+
return typeof window === 'undefined' ? undefined : window.indexedDB;
|
|
393
|
+
} catch (unused) {
|
|
394
|
+
return undefined;
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
const browserLocalStorage = ()=>{
|
|
398
|
+
try {
|
|
399
|
+
return typeof window === 'undefined' ? undefined : window.localStorage;
|
|
400
|
+
} catch (unused) {
|
|
401
|
+
return undefined;
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
/**
|
|
405
|
+
* Runs the handoff for a Firestore that `initializeFirestore` has just
|
|
406
|
+
* configured with the durable multi-tab cache under `prefix`
|
|
407
|
+
* (`firestorePersistencePrefix`). Call it in the same task as
|
|
408
|
+
* `initializeFirestore`, before any read: its first step is the synchronous
|
|
409
|
+
* snapshot of the clients that are not this tab. Fire and forget: it never
|
|
410
|
+
* rejects, and every failure leaves the SDK's own election in charge.
|
|
411
|
+
*/ export function startFirestoreTabHandoff(firestore, prefix) {
|
|
412
|
+
const factory = browserIndexedDb();
|
|
413
|
+
const storage = browserLocalStorage();
|
|
414
|
+
const databaseName = `${prefix}main`;
|
|
415
|
+
return runTabHandoff({
|
|
416
|
+
isVisible: ()=>typeof document !== 'undefined' && document.visibilityState === 'visible',
|
|
417
|
+
isOnline: ()=>typeof navigator === 'undefined' || navigator.onLine !== false,
|
|
418
|
+
readClientIds: ()=>readSharedClientIds(storage, prefix),
|
|
419
|
+
startClient: ()=>enableNetwork(firestore),
|
|
420
|
+
claim: (selfId)=>claimPrimaryLease(factory, databaseName, selfId),
|
|
421
|
+
cycleNetwork: async ()=>{
|
|
422
|
+
await disableNetwork(firestore);
|
|
423
|
+
await enableNetwork(firestore);
|
|
424
|
+
}
|
|
425
|
+
}).then((outcome)=>{
|
|
426
|
+
if (outcome.result === 'claimed') {
|
|
427
|
+
console.debug('[firestore] This visible tab took the primary lease from a background tab (AGL-3660).', outcome);
|
|
428
|
+
}
|
|
429
|
+
return outcome;
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
//# sourceMappingURL=firestore-tab-handoff.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/firebase/firestore-tab-handoff.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\nimport {\n disableNetwork,\n enableNetwork,\n type Firestore,\n} from 'firebase/firestore'\n\n/**\n * A NEWLY OPENED, VISIBLE TAB TAKES THE PRIMARY LEASE FROM A HIDDEN ONE\n * (AGL-3660).\n *\n * ## What the SDK does on its own\n *\n * With `persistentMultipleTabManager`, exactly one tab per origin — the\n * holder of the `owner` row in `firestore/<app>/<project>/main` — runs the\n * network. Every other tab sends its queries to that tab through\n * `localStorage` and reads the answers back out of IndexedDB. Read against\n * `@firebase/firestore` 4.17.2 (`IndexedDbPersistence`):\n *\n * - **A valid lease is never handed over.** `canActAsPrimary` returns `true`\n * for the holder whenever its own lease is under 5 s old\n * (`MAX_PRIMARY_ELIGIBLE_AGE_MS`) and its network is enabled, whatever its\n * visibility. The \"prefer the foreground tab\" rule only applies once the\n * lease has lapsed. A console tab the user left in the background therefore\n * keeps serving the tab they are looking at, for as long as its 4 s renewal\n * timer keeps firing.\n * - **A background holder is a slow server.** Its renewal timer is throttled\n * (production lease rows, 2026-10-08: renewed every ~4.9 s, against a 5 s\n * lapse), and every query the new tab issues waits on that tab's event loop.\n * The workspace read on a fresh `/aglyn-org/media` tab came back only once\n * the new tab had won the lease on a lapse, its own Listen channel opening\n * ~4.7 s in (PR #1313).\n * - **The new tab's first record says it is hidden.** `start()` runs its\n * first election (`updateClientMetadataAndTryBecomePrimary`) before\n * `attachVisibilityHandler()` has read `document.visibilityState`, so the\n * `clientMetadata` row it writes carries `inForeground: false` until the\n * next 4 s tick. When the holder's lease does lapse in that window, the\n * holder finds no better candidate and takes it straight back.\n *\n * ## What this does\n *\n * Once, at boot, in a tab that is visible and online:\n *\n * 1. Snapshot the client ids already registered, synchronously, in the same\n * task as `initializeFirestore` — before anything can start this tab's\n * client. They come from the SDK's `firestore_clients_<prefix>_<id>` keys\n * in `localStorage`, which a tab writes once its persistence has started.\n * 2. Start this tab's Firestore (`enableNetwork` — a no-op on a client whose\n * network is on — resolves once persistence has started and the tab has\n * run its first election). The one id that was not there before is this\n * tab.\n * 3. In ONE IndexedDB transaction over `owner`, `clientMetadata` and\n * `mutations`, decide with {@link decideLeaseClaim}. When the holder is\n * another tab that has recorded itself as hidden and no write is pending,\n * write this tab's row as foreground and the lease in this tab's name.\n * 4. Cycle this tab's network (`disableNetwork` then `enableNetwork`): each\n * step runs the SDK's own election at once, and the second finds a valid\n * lease that is this tab's and becomes primary.\n *\n * ## Why this is safe\n *\n * - **It is the SDK's own handoff.** A holder that finds the lease in another\n * tab's name is exactly a holder whose lease lapsed: every\n * `readwrite-primary` transaction re-verifies the lease inside the\n * transaction (`verifyPrimaryLease`), fails, flips the tab to secondary and\n * throws the `failed-precondition` its sync engine already swallows\n * (`ignoreIfPrimaryLeaseLoss`). IndexedDB serialises our transaction against\n * the SDK's (which span every store), so no election interleaves with it.\n * - **The old holder cannot take it back.** The lease is valid and not its\n * own, so its `canActAsPrimary` is `false`. Should this tab fail to renew,\n * the lease lapses in 5 s and the corrected `inForeground: true` row makes\n * this tab the preferred candidate, not the hidden one.\n * - **Never with a write in flight.** The holder sends pending mutations; a\n * new primary resends every unacknowledged batch. Any row in `mutations`\n * skips the claim, so a write is never sent twice by two tabs.\n * - **Never from a visible holder.** A tab visible in another window serves\n * quickly (measured in production: ~390 ms from a query target written to\n * it being `current`), and taking its lease would only move the work.\n * - **The cycle is invisible in a secondary.** The sync engine ignores its\n * own remote store's online state while secondary\n * (`syncEngineApplyOnlineStateChange`), so no listener sees an offline\n * flicker. If this tab somehow became primary in between, the cycle is the\n * same one `firestore-stall-recovery.ts` runs.\n * - **Every failure is the old behaviour.** Anything unexpected — no\n * database, the store locked (the AGL-3428 wedge), two new tabs at once, a\n * holder that is visible — skips the claim, and the SDK's own election runs\n * as it always has.\n *\n * Not chosen: `persistentSingleTabManager` (rejected in AGL-3373/3428, and\n * `forceOwnership` there fails every other tab with\n * `PRIMARY_LEASE_EXCLUSIVE_ERROR_MSG`); `getDocFromServer` (a secondary routes\n * it through the holder like any listen); a per-tab memory cache for the first\n * read (the cache is fixed at `initializeFirestore`); a synthetic\n * `visibilitychange` to rerun the election (also fires the console's own\n * twenty-odd visibility listeners).\n */\n\n/** The SDK's lease lifetime (`MAX_PRIMARY_ELIGIBLE_AGE_MS`). */\nexport const PRIMARY_LEASE_MAX_AGE_MS = 5_000\n\n/**\n * How long the claim transaction may wait to start. The SDK's own take\n * milliseconds; one that cannot start is behind the AGL-3428 wedge, which\n * `firestore-multitab-wedge.ts` handles.\n */\nexport const CLAIM_TIMEOUT_MS = 1_500\n\n/** The SDK's stores and the lease row's key (`indexeddb_sentinels.ts`). */\nconst OWNER_STORE = 'owner'\nconst OWNER_KEY = 'owner'\nconst CLIENT_METADATA_STORE = 'clientMetadata'\nconst MUTATIONS_STORE = 'mutations'\n\n/** `DbPrimaryClient`. */\nexport interface LeaseRow {\n ownerId: string\n allowTabSynchronization: boolean\n leaseTimestampMs: number\n}\n\n/** `DbClientMetadata`. */\nexport interface ClientRow {\n clientId: string\n updateTimeMs: number\n networkEnabled: boolean\n inForeground: boolean\n}\n\n/** Why the claim was not made. */\nexport type LeaseClaimSkip =\n | 'unidentified'\n | 'vacant'\n | 'already-primary'\n | 'exclusive-holder'\n | 'holder-unknown'\n | 'holder-visible'\n | 'pending-writes'\n\nexport type LeaseClaimDecision =\n { claim: true } | { claim: false; reason: LeaseClaimSkip }\n\n/**\n * Whether this tab (`self`) should take the lease `owner` holds. Pure: the\n * caller reads every input inside the one transaction that writes the result.\n *\n * A vacant or lapsed-and-vacant lease is left to the SDK, whose next election\n * takes it; only a lease another tab holds is ever claimed, and only from a\n * holder whose own row says it is in the background.\n */\nexport function decideLeaseClaim(input: {\n owner: LeaseRow | undefined\n holder: ClientRow | undefined\n self: ClientRow | undefined\n pendingWrites: number\n}): LeaseClaimDecision {\n const { owner, holder, self, pendingWrites } = input\n if (!self) return { claim: false, reason: 'unidentified' }\n if (!owner) return { claim: false, reason: 'vacant' }\n if (owner.ownerId === self.clientId)\n return { claim: false, reason: 'already-primary' }\n // A single-tab holder never shares; `persistentMultipleTabManager` never\n // writes `false`, so this is another configuration on the origin.\n if (!owner.allowTabSynchronization)\n return { claim: false, reason: 'exclusive-holder' }\n if (pendingWrites > 0) return { claim: false, reason: 'pending-writes' }\n if (!holder) return { claim: false, reason: 'holder-unknown' }\n if (holder.inForeground) return { claim: false, reason: 'holder-visible' }\n return { claim: true }\n}\n\n/** What the boot handoff did. */\nexport type TabHandoffOutcome =\n | { result: 'claimed'; fromOwnerId: string; leaseAgeMs: number }\n | {\n result: 'skipped'\n reason:\n | LeaseClaimSkip\n | 'hidden'\n | 'offline'\n | 'alone'\n | 'no-database'\n | 'locked'\n | 'error'\n }\n\n/** What one claim transaction found and did. */\nexport type ClaimTransactionResult =\n | { state: 'claimed'; fromOwnerId: string; leaseAgeMs: number }\n | { state: 'skipped'; reason: LeaseClaimSkip }\n | { state: 'locked' }\n | { state: 'unavailable' }\n\nexport interface TabHandoffDeps {\n isVisible: () => boolean\n isOnline: () => boolean\n /**\n * Client ids registered on this origin. Must be synchronous: the first call\n * is the snapshot taken before this tab's client can start.\n */\n readClientIds: () => ReadonlySet<string>\n /** Starts this tab's Firestore; resolves once its persistence has started. */\n startClient: () => Promise<void>\n /** Runs the claim for `selfId` in one transaction. */\n claim: (selfId: string | undefined) => Promise<ClaimTransactionResult>\n /** `disableNetwork` then `enableNetwork`: two elections, at once. */\n cycleNetwork: () => Promise<void>\n}\n\n/**\n * The boot handoff, with its browser parts injected. Never rejects.\n */\nexport async function runTabHandoff(\n deps: TabHandoffDeps,\n): Promise<TabHandoffOutcome> {\n try {\n if (!deps.isVisible()) return { result: 'skipped', reason: 'hidden' }\n if (!deps.isOnline()) return { result: 'skipped', reason: 'offline' }\n // Before this tab's Firestore starts, or this tab would be in it.\n const before = deps.readClientIds()\n // No other tab: this one wins the lease at its first election.\n if (before.size === 0) return { result: 'skipped', reason: 'alone' }\n await deps.startClient()\n // The user may have switched away while the client started.\n if (!deps.isVisible()) return { result: 'skipped', reason: 'hidden' }\n const added = [...deps.readClientIds()].filter((id) => !before.has(id))\n // Exactly one new client is this tab. None means this tab's Firestore\n // started before the snapshot; two means another tab started alongside.\n // Either way this tab cannot be told apart, so nothing is claimed.\n if (added.length !== 1) return { result: 'skipped', reason: 'unidentified' }\n const selfId = added[0]\n const outcome = await deps.claim(selfId)\n if (outcome.state === 'locked')\n return { result: 'skipped', reason: 'locked' }\n if (outcome.state === 'unavailable')\n return { result: 'skipped', reason: 'no-database' }\n if (outcome.state === 'skipped')\n return { result: 'skipped', reason: outcome.reason }\n await deps.cycleNetwork()\n return {\n result: 'claimed',\n fromOwnerId: outcome.fromOwnerId,\n leaseAgeMs: outcome.leaseAgeMs,\n }\n } catch {\n return { result: 'skipped', reason: 'error' }\n }\n}\n\n/**\n * Opens `databaseName` only if it already exists, at its current version —\n * never creating it under the SDK (the rule `probePrimaryLease` follows) —\n * and closes on `versionchange` so an SDK upgrade is never held up.\n */\nasync function openExisting(\n factory: IDBFactory,\n databaseName: string,\n): Promise<IDBDatabase | undefined> {\n if (typeof factory.databases !== 'function') return undefined\n const databases = await factory.databases()\n if (!databases.some((database) => database.name === databaseName))\n return undefined\n return new Promise((resolve) => {\n let request: IDBOpenDBRequest\n try {\n request = factory.open(databaseName)\n } catch {\n resolve(undefined)\n return\n }\n request.onupgradeneeded = () => request.transaction?.abort()\n request.onerror = () => resolve(undefined)\n request.onblocked = () => resolve(undefined)\n request.onsuccess = () => {\n const database = request.result\n database.onversionchange = () => database.close()\n resolve(database)\n }\n })\n}\n\nconst hasStores = (database: IDBDatabase, stores: string[]) =>\n stores.every((store) => database.objectStoreNames.contains(store))\n\nconst escapeRegExp = (text: string) =>\n text.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n\n/**\n * The client ids the SDK has registered under `prefix`\n * (`firestore/<app>/<project>/`), from its `firestore_clients_<prefix>_<id>`\n * keys. Synchronous, so a snapshot can be taken before this tab's client\n * starts. A key whose tab has since closed is harmless here: only ids that\n * APPEAR between two snapshots are read as this tab.\n */\nexport function readSharedClientIds(\n storage: Pick<Storage, 'length' | 'key'> | undefined,\n prefix: string,\n): ReadonlySet<string> {\n const ids = new Set<string>()\n if (!storage) return ids\n // The SDK's own key shape: a client id never contains `_`.\n const clientKey = new RegExp(\n `^firestore_clients_${escapeRegExp(prefix)}_([^_]+)$`,\n )\n try {\n for (let i = 0; i < storage.length; i++) {\n const match = clientKey.exec(storage.key(i) ?? '')\n if (match) ids.add(match[1])\n }\n } catch {\n // Site data blocked: no ids, so the handoff stands down.\n }\n return ids\n}\n\n/**\n * Reads the lease, both client rows and the pending-write count, decides, and\n * writes the claim — all in one `readwrite` transaction, so no election runs\n * between the read and the write. Aborted when it cannot start within\n * `timeoutMs`.\n */\nexport async function claimPrimaryLease(\n factory: IDBFactory | undefined,\n databaseName: string,\n selfId: string | undefined,\n {\n timeoutMs = CLAIM_TIMEOUT_MS,\n now = Date.now,\n }: { timeoutMs?: number; now?: () => number } = {},\n): Promise<ClaimTransactionResult> {\n if (!factory) return { state: 'unavailable' }\n const database = await openExisting(factory, databaseName).catch(\n () => undefined,\n )\n if (!database) return { state: 'unavailable' }\n const stores = [OWNER_STORE, CLIENT_METADATA_STORE, MUTATIONS_STORE]\n if (!hasStores(database, stores)) {\n database.close()\n return { state: 'unavailable' }\n }\n return new Promise<ClaimTransactionResult>((resolve) => {\n let transaction: IDBTransaction\n try {\n transaction = database.transaction(stores, 'readwrite')\n } catch {\n database.close()\n resolve({ state: 'unavailable' })\n return\n }\n let settled = false\n let started = false\n let result: ClaimTransactionResult = { state: 'unavailable' }\n // Only a transaction that cannot START is aborted: once the lease read\n // has landed, the decision and its writes take milliseconds.\n const timer = setTimeout(() => {\n if (started) return\n try {\n transaction.abort()\n } catch {\n // Already finished.\n }\n finish({ state: 'locked' })\n }, timeoutMs)\n const finish = (value: ClaimTransactionResult) => {\n if (settled) return\n settled = true\n clearTimeout(timer)\n database.close()\n resolve(value)\n }\n transaction.oncomplete = () => finish(result)\n transaction.onabort = () =>\n finish(started ? { state: 'unavailable' } : { state: 'locked' })\n transaction.onerror = () => finish({ state: 'unavailable' })\n\n const owners = transaction.objectStore(OWNER_STORE)\n const clients = transaction.objectStore(CLIENT_METADATA_STORE)\n const ownerRequest = owners.get(OWNER_KEY)\n ownerRequest.onsuccess = () => {\n started = true\n const owner = ownerRequest.result as LeaseRow | undefined\n const selfRequest = selfId === undefined ? undefined : clients.get(selfId)\n const holderRequest = owner?.ownerId\n ? clients.get(owner.ownerId)\n : undefined\n const countRequest = transaction.objectStore(MUTATIONS_STORE).count()\n countRequest.onsuccess = () => {\n // Requests in one transaction complete in order: both gets landed.\n const self = selfRequest?.result as ClientRow | undefined\n const holder = holderRequest?.result as ClientRow | undefined\n const decision = decideLeaseClaim({\n owner,\n holder,\n self,\n pendingWrites: countRequest.result,\n })\n if ('reason' in decision) {\n result = { state: 'skipped', reason: decision.reason }\n return\n }\n const at = now()\n // The row the SDK's first election should have written, then the\n // lease in this tab's name — `acquireOrExtendPrimaryLease`'s shape.\n clients.put({ ...self, inForeground: true, updateTimeMs: at })\n owners.put(\n {\n ownerId: self!.clientId,\n allowTabSynchronization: true,\n leaseTimestampMs: at,\n } satisfies LeaseRow,\n OWNER_KEY,\n )\n result = {\n state: 'claimed',\n fromOwnerId: owner!.ownerId,\n leaseAgeMs: at - owner!.leaseTimestampMs,\n }\n }\n }\n })\n}\n\nconst browserIndexedDb = (): IDBFactory | undefined => {\n try {\n return typeof window === 'undefined' ? undefined : window.indexedDB\n } catch {\n return undefined\n }\n}\n\nconst browserLocalStorage = (): Storage | undefined => {\n try {\n return typeof window === 'undefined' ? undefined : window.localStorage\n } catch {\n return undefined\n }\n}\n\n/**\n * Runs the handoff for a Firestore that `initializeFirestore` has just\n * configured with the durable multi-tab cache under `prefix`\n * (`firestorePersistencePrefix`). Call it in the same task as\n * `initializeFirestore`, before any read: its first step is the synchronous\n * snapshot of the clients that are not this tab. Fire and forget: it never\n * rejects, and every failure leaves the SDK's own election in charge.\n */\nexport function startFirestoreTabHandoff(\n firestore: Firestore,\n prefix: string,\n): Promise<TabHandoffOutcome> {\n const factory = browserIndexedDb()\n const storage = browserLocalStorage()\n const databaseName = `${prefix}main`\n return runTabHandoff({\n isVisible: () =>\n typeof document !== 'undefined' && document.visibilityState === 'visible',\n isOnline: () =>\n typeof navigator === 'undefined' || navigator.onLine !== false,\n readClientIds: () => readSharedClientIds(storage, prefix),\n startClient: () => enableNetwork(firestore),\n claim: (selfId) => claimPrimaryLease(factory, databaseName, selfId),\n cycleNetwork: async () => {\n await disableNetwork(firestore)\n await enableNetwork(firestore)\n },\n }).then((outcome) => {\n if (outcome.result === 'claimed') {\n console.debug(\n '[firestore] This visible tab took the primary lease from a background tab (AGL-3660).',\n outcome,\n )\n }\n return outcome\n })\n}\n"],"names":["disableNetwork","enableNetwork","PRIMARY_LEASE_MAX_AGE_MS","CLAIM_TIMEOUT_MS","OWNER_STORE","OWNER_KEY","CLIENT_METADATA_STORE","MUTATIONS_STORE","decideLeaseClaim","input","owner","holder","self","pendingWrites","claim","reason","ownerId","clientId","allowTabSynchronization","inForeground","runTabHandoff","deps","isVisible","result","isOnline","before","readClientIds","size","startClient","added","filter","id","has","length","selfId","outcome","state","cycleNetwork","fromOwnerId","leaseAgeMs","openExisting","factory","databaseName","databases","undefined","some","database","name","Promise","resolve","request","open","onupgradeneeded","transaction","abort","onerror","onblocked","onsuccess","onversionchange","close","hasStores","stores","every","store","objectStoreNames","contains","escapeRegExp","text","replace","readSharedClientIds","storage","prefix","ids","Set","clientKey","RegExp","i","match","exec","key","add","claimPrimaryLease","timeoutMs","now","Date","catch","settled","started","timer","setTimeout","finish","value","clearTimeout","oncomplete","onabort","owners","objectStore","clients","ownerRequest","get","selfRequest","holderRequest","countRequest","count","decision","at","put","updateTimeMs","leaseTimestampMs","browserIndexedDb","window","indexedDB","browserLocalStorage","localStorage","startFirestoreTabHandoff","firestore","document","visibilityState","navigator","onLine","then","console","debug"],"mappings":"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"}
|
|
@@ -38,6 +38,12 @@ import { useFirestore, useUser } from "./firebase/firebase-services.js";
|
|
|
38
38
|
* failure carries no information the first did not, and a message per edit
|
|
39
39
|
* buries the diagnostics that do.
|
|
40
40
|
*/ function reportDroppedEntry(error) {
|
|
41
|
+
// `already-exists` is not a dropped entry: it is the entry. `addDoc` is a
|
|
42
|
+
// create under a fresh id, so the only way it meets an existing document is
|
|
43
|
+
// the SDK replaying a write whose commit landed and whose answer did not (a
|
|
44
|
+
// flaky connection, a tab waking from sleep). The line is in the log; two
|
|
45
|
+
// beacon reports on 2026-10-09 paged for exactly that.
|
|
46
|
+
if ((error == null ? void 0 : error.code) === 'already-exists') return;
|
|
41
47
|
reportHandledError(error, {
|
|
42
48
|
kind: 'host-activity-write'
|
|
43
49
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-host-activity-logger.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 { activitySearchTokens } from '@aglyn/aglyn/app-utils/activity-search'\nimport { reportHandledError } from '@aglyn/aglyn/app-utils/error-beacon'\nimport { Timestamp } from '@aglyn/shared-util-timestamp'\nimport { addDoc, collection } from 'firebase/firestore'\nimport { useCallback } from 'react'\nimport { useFirestore, useUser } from './firebase/firebase-services'\n\n/** What an activity entry points at; `id` lets detail pages filter. */\nexport interface HostActivityTarget {\n type:\n | 'host'\n | 'screen'\n | 'layout'\n | 'theme'\n | 'media'\n | 'content'\n | 'variable'\n | 'function'\n | 'workflow'\n | 'member'\n // Standalone editors (AGL-680/681) — both are edited on their own now,\n // so their saves belong in the same log as screens and layouts.\n | 'component'\n | 'template'\n // A person on the CRM record page (AGL-2596): added by hand, edited, or\n // removed from the site. The record is org-shared, but the act happened\n // in THIS site's console and belongs in this site's feed.\n | 'contact'\n // The rest of the CRM (AGL-2622): a company or a deal created or deleted\n // from this site's console — one at a time, or one line per record from\n // the bulk bars (AGL-2621) — and a lead converted into the three. Org\n // data all of them, filed under the site whose console did the work for\n // the reason a contact is.\n | 'company'\n | 'deal'\n | 'lead'\n id?: string\n name?: string\n /**\n * Version-addressed targets (screens) record this so the feed can deep-link\n * straight to the exact version's detail view (AGL-812). Written only when\n * truthy, like `id`/`name`.\n */\n versionId?: string\n}\n\n/** One console line per session, however many appends fail. */\nlet announced = false\n\n/**\n * What a dropped audit append does instead of nothing.\n *\n * The append stays fire-and-forget: an audit miss must never break the edit\n * that triggered it, and a snackbar about the LOG would be a worse answer to\n * \"your page saved\" than silence. But a rejection with nowhere to go is a\n * feature that can break for months without a single signal, so the failure\n * leaves by two doors that cost the person nothing.\n *\n * The beacon is the durable one — it reaches Cloud Error Reporting, dedupes a\n * failure that repeats on every edit into one report, and caps itself, so a\n * permission denial shows up as a graph instead of a support ticket. The\n * console line is for whoever has devtools open, and fires once: the second\n * failure carries no information the first did not, and a message per edit\n * buries the diagnostics that do.\n */\nfunction reportDroppedEntry(error: unknown): void {\n reportHandledError(error, { kind: 'host-activity-write' })\n if (announced) return\n announced = true\n console.error(\n 'Activity logging failed; entries from this session are being dropped.',\n error,\n )\n}\n\n/**\n * User activity log (AGL-118): fire-and-forget appends to\n * `hosts/{hostId}/activity` from console mutation points. Never throws —\n * an audit miss must not break the edit that triggered it. Covered by the\n * host-admin wildcard rule, so no rules change is needed.\n *\n * `activity` is deliberately absent from the host catch-all's create\n * exclusion list, so a member holding a content role writes here directly.\n * `cloud/rules-tests/firestore-rules.test.mjs` pins that on both sides —\n * the roles that may append and the roles that may not.\n *\n * An append needs an ACTOR, and the rule that admits it needs one too:\n * `canWriteHostContent` resolves the writer's host role out of\n * `memberRoles[request.auth.uid]`, so a request carrying no auth matches no\n * role and is refused. `useUser()` reports `undefined` until\n * `onIdTokenChanged` first fires and `null` once it reports a signed-out\n * tab, and in both states the entry has nobody to attribute — so the append\n * is skipped rather than sent to be denied. An unattributed audit line is\n * not a smaller version of an audit line; it records that something happened\n * and refuses to say who did it, which is the one field the log exists for.\n */\nexport function useHostActivityLogger(hostId: string | undefined) {\n const firestore = useFirestore()\n const { data: user } = useUser()\n return useCallback(\n (action: string, target: HostActivityTarget) => {\n if (!hostId || !user) return\n const actorEmail = user.email ?? null\n void addDoc(collection(firestore, 'hosts', hostId, 'activity'), {\n actorId: user.uid,\n actorEmail,\n action,\n target: {\n type: target.type,\n ...(target.id ? { id: target.id } : {}),\n ...(target.name ? { name: target.name } : {}),\n ...(target.versionId ? { versionId: target.versionId } : {}),\n },\n // What the log's search box finds the entry by (AGL-3321). The host\n // catch-all rule validates no keys, so the field needs no rule.\n searchTokens: activitySearchTokens({ actorEmail, target }),\n createdAt: Timestamp.now(),\n }).catch(reportDroppedEntry)\n },\n [firestore, hostId, user],\n )\n}\n\nexport default useHostActivityLogger\n"],"names":["activitySearchTokens","reportHandledError","Timestamp","addDoc","collection","useCallback","useFirestore","useUser","announced","reportDroppedEntry","error","kind","console","useHostActivityLogger","hostId","firestore","data","user","action","target","actorEmail","email","actorId","uid","type","id","name","versionId","searchTokens","createdAt","now","catch"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SAASA,oBAAoB,QAAQ,yCAAwC;AAC7E,SAASC,kBAAkB,QAAQ,sCAAqC;AACxE,SAASC,SAAS,QAAQ,+BAA8B;AACxD,SAASC,MAAM,EAAEC,UAAU,QAAQ,qBAAoB;AACvD,SAASC,WAAW,QAAQ,QAAO;AACnC,SAASC,YAAY,EAAEC,OAAO,QAAQ,kCAA8B;AAyCpE,6DAA6D,GAC7D,IAAIC,YAAY;AAEhB;;;;;;;;;;;;;;;CAeC,GACD,SAASC,mBAAmBC,KAAc;
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-host-activity-logger.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 { activitySearchTokens } from '@aglyn/aglyn/app-utils/activity-search'\nimport { reportHandledError } from '@aglyn/aglyn/app-utils/error-beacon'\nimport { Timestamp } from '@aglyn/shared-util-timestamp'\nimport { addDoc, collection } from 'firebase/firestore'\nimport { useCallback } from 'react'\nimport { useFirestore, useUser } from './firebase/firebase-services'\n\n/** What an activity entry points at; `id` lets detail pages filter. */\nexport interface HostActivityTarget {\n type:\n | 'host'\n | 'screen'\n | 'layout'\n | 'theme'\n | 'media'\n | 'content'\n | 'variable'\n | 'function'\n | 'workflow'\n | 'member'\n // Standalone editors (AGL-680/681) — both are edited on their own now,\n // so their saves belong in the same log as screens and layouts.\n | 'component'\n | 'template'\n // A person on the CRM record page (AGL-2596): added by hand, edited, or\n // removed from the site. The record is org-shared, but the act happened\n // in THIS site's console and belongs in this site's feed.\n | 'contact'\n // The rest of the CRM (AGL-2622): a company or a deal created or deleted\n // from this site's console — one at a time, or one line per record from\n // the bulk bars (AGL-2621) — and a lead converted into the three. Org\n // data all of them, filed under the site whose console did the work for\n // the reason a contact is.\n | 'company'\n | 'deal'\n | 'lead'\n id?: string\n name?: string\n /**\n * Version-addressed targets (screens) record this so the feed can deep-link\n * straight to the exact version's detail view (AGL-812). Written only when\n * truthy, like `id`/`name`.\n */\n versionId?: string\n}\n\n/** One console line per session, however many appends fail. */\nlet announced = false\n\n/**\n * What a dropped audit append does instead of nothing.\n *\n * The append stays fire-and-forget: an audit miss must never break the edit\n * that triggered it, and a snackbar about the LOG would be a worse answer to\n * \"your page saved\" than silence. But a rejection with nowhere to go is a\n * feature that can break for months without a single signal, so the failure\n * leaves by two doors that cost the person nothing.\n *\n * The beacon is the durable one — it reaches Cloud Error Reporting, dedupes a\n * failure that repeats on every edit into one report, and caps itself, so a\n * permission denial shows up as a graph instead of a support ticket. The\n * console line is for whoever has devtools open, and fires once: the second\n * failure carries no information the first did not, and a message per edit\n * buries the diagnostics that do.\n */\nfunction reportDroppedEntry(error: unknown): void {\n // `already-exists` is not a dropped entry: it is the entry. `addDoc` is a\n // create under a fresh id, so the only way it meets an existing document is\n // the SDK replaying a write whose commit landed and whose answer did not (a\n // flaky connection, a tab waking from sleep). The line is in the log; two\n // beacon reports on 2026-10-09 paged for exactly that.\n if ((error as { code?: unknown } | null)?.code === 'already-exists') return\n reportHandledError(error, { kind: 'host-activity-write' })\n if (announced) return\n announced = true\n console.error(\n 'Activity logging failed; entries from this session are being dropped.',\n error,\n )\n}\n\n/**\n * User activity log (AGL-118): fire-and-forget appends to\n * `hosts/{hostId}/activity` from console mutation points. Never throws —\n * an audit miss must not break the edit that triggered it. Covered by the\n * host-admin wildcard rule, so no rules change is needed.\n *\n * `activity` is deliberately absent from the host catch-all's create\n * exclusion list, so a member holding a content role writes here directly.\n * `cloud/rules-tests/firestore-rules.test.mjs` pins that on both sides —\n * the roles that may append and the roles that may not.\n *\n * An append needs an ACTOR, and the rule that admits it needs one too:\n * `canWriteHostContent` resolves the writer's host role out of\n * `memberRoles[request.auth.uid]`, so a request carrying no auth matches no\n * role and is refused. `useUser()` reports `undefined` until\n * `onIdTokenChanged` first fires and `null` once it reports a signed-out\n * tab, and in both states the entry has nobody to attribute — so the append\n * is skipped rather than sent to be denied. An unattributed audit line is\n * not a smaller version of an audit line; it records that something happened\n * and refuses to say who did it, which is the one field the log exists for.\n */\nexport function useHostActivityLogger(hostId: string | undefined) {\n const firestore = useFirestore()\n const { data: user } = useUser()\n return useCallback(\n (action: string, target: HostActivityTarget) => {\n if (!hostId || !user) return\n const actorEmail = user.email ?? null\n void addDoc(collection(firestore, 'hosts', hostId, 'activity'), {\n actorId: user.uid,\n actorEmail,\n action,\n target: {\n type: target.type,\n ...(target.id ? { id: target.id } : {}),\n ...(target.name ? { name: target.name } : {}),\n ...(target.versionId ? { versionId: target.versionId } : {}),\n },\n // What the log's search box finds the entry by (AGL-3321). The host\n // catch-all rule validates no keys, so the field needs no rule.\n searchTokens: activitySearchTokens({ actorEmail, target }),\n createdAt: Timestamp.now(),\n }).catch(reportDroppedEntry)\n },\n [firestore, hostId, user],\n )\n}\n\nexport default useHostActivityLogger\n"],"names":["activitySearchTokens","reportHandledError","Timestamp","addDoc","collection","useCallback","useFirestore","useUser","announced","reportDroppedEntry","error","code","kind","console","useHostActivityLogger","hostId","firestore","data","user","action","target","actorEmail","email","actorId","uid","type","id","name","versionId","searchTokens","createdAt","now","catch"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SAASA,oBAAoB,QAAQ,yCAAwC;AAC7E,SAASC,kBAAkB,QAAQ,sCAAqC;AACxE,SAASC,SAAS,QAAQ,+BAA8B;AACxD,SAASC,MAAM,EAAEC,UAAU,QAAQ,qBAAoB;AACvD,SAASC,WAAW,QAAQ,QAAO;AACnC,SAASC,YAAY,EAAEC,OAAO,QAAQ,kCAA8B;AAyCpE,6DAA6D,GAC7D,IAAIC,YAAY;AAEhB;;;;;;;;;;;;;;;CAeC,GACD,SAASC,mBAAmBC,KAAc;IACxC,0EAA0E;IAC1E,4EAA4E;IAC5E,4EAA4E;IAC5E,0EAA0E;IAC1E,uDAAuD;IACvD,IAAI,CAACA,yBAAD,AAACA,MAAqCC,IAAI,MAAK,kBAAkB;IACrEV,mBAAmBS,OAAO;QAAEE,MAAM;IAAsB;IACxD,IAAIJ,WAAW;IACfA,YAAY;IACZK,QAAQH,KAAK,CACX,yEACAA;AAEJ;AAEA;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,SAASI,sBAAsBC,MAA0B;IAC9D,MAAMC,YAAYV;IAClB,MAAM,EAAEW,MAAMC,IAAI,EAAE,GAAGX;IACvB,OAAOF,YACL,CAACc,QAAgBC;YAEIF;QADnB,IAAI,CAACH,UAAU,CAACG,MAAM;QACtB,MAAMG,cAAaH,cAAAA,KAAKI,KAAK,YAAVJ,cAAc;QACjC,KAAKf,OAAOC,WAAWY,WAAW,SAASD,QAAQ,aAAa;YAC9DQ,SAASL,KAAKM,GAAG;YACjBH;YACAF;YACAC,QAAQ;gBACNK,MAAML,OAAOK,IAAI;eACbL,OAAOM,EAAE,GAAG;gBAAEA,IAAIN,OAAOM,EAAE;YAAC,IAAI,CAAC,GACjCN,OAAOO,IAAI,GAAG;gBAAEA,MAAMP,OAAOO,IAAI;YAAC,IAAI,CAAC,GACvCP,OAAOQ,SAAS,GAAG;gBAAEA,WAAWR,OAAOQ,SAAS;YAAC,IAAI,CAAC;YAE5D,oEAAoE;YACpE,gEAAgE;YAChEC,cAAc7B,qBAAqB;gBAAEqB;gBAAYD;YAAO;YACxDU,WAAW5B,UAAU6B,GAAG;QAC1B,GAAGC,KAAK,CAACvB;IACX,GACA;QAACO;QAAWD;QAAQG;KAAK;AAE7B;AAEA,eAAeJ,sBAAqB"}
|