@aglyn/tenant-feature-instance 1.0.0-beta.218 → 1.0.0-beta.220
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/README.md +6 -2
- package/package.json +7 -7
- package/src/lib/components/container-picker.d.ts +70 -0
- package/src/lib/components/container-picker.js +140 -0
- package/src/lib/components/container-picker.js.map +1 -0
- package/src/lib/hooks/firebase/firebase-services.js +15 -3
- package/src/lib/hooks/firebase/firebase-services.js.map +1 -1
- package/src/lib/hooks/firebase/firestore-cache.d.ts +13 -0
- package/src/lib/hooks/firebase/firestore-cache.js +16 -0
- package/src/lib/hooks/firebase/firestore-cache.js.map +1 -1
- package/src/lib/hooks/firebase/firestore-multitab-wedge.d.ts +200 -0
- package/src/lib/hooks/firebase/firestore-multitab-wedge.js +325 -0
- package/src/lib/hooks/firebase/firestore-multitab-wedge.js.map +1 -0
- package/src/lib/hooks/firebase/firestore-stall-recovery.d.ts +29 -2
- package/src/lib/hooks/firebase/firestore-stall-recovery.js +41 -11
- package/src/lib/hooks/firebase/firestore-stall-recovery.js.map +1 -1
- package/src/lib/hooks/use-container-options.d.ts +98 -0
- package/src/lib/hooks/use-container-options.js +174 -0
- package/src/lib/hooks/use-container-options.js.map +1 -0
- package/src/lib/hooks/use-host-document-version.d.ts +52 -0
- package/src/lib/hooks/{use-form-version.js → use-host-document-version.js} +6 -16
- package/src/lib/hooks/use-host-document-version.js.map +1 -0
- package/src/lib/hooks/use-org-member-options.d.ts +1 -1
- package/src/lib/hooks/use-org-member-options.js +1 -1
- package/src/lib/hooks/use-org-member-options.js.map +1 -1
- package/src/lib/tenant.d.ts +2 -2
- package/src/lib/tenant.js +2 -2
- package/src/lib/tenant.js.map +1 -1
- package/src/lib/hooks/use-form-version.d.ts +0 -44
- package/src/lib/hooks/use-form-version.js.map +0 -1
- package/src/lib/hooks/use-host-campaigns.d.ts +0 -98
- package/src/lib/hooks/use-host-campaigns.js +0 -160
- package/src/lib/hooks/use-host-campaigns.js.map +0 -1
|
@@ -83,6 +83,19 @@ import { type Firestore } from 'firebase/firestore';
|
|
|
83
83
|
* a page stalls together, and one cycle serves them all. A concurrent
|
|
84
84
|
* request shares the cycle already running.
|
|
85
85
|
* - **The network is always re-enabled**, including when disabling fails.
|
|
86
|
+
* - **Each step is bounded by {@link NETWORK_STEP_TIMEOUT_MS}.** Both calls are
|
|
87
|
+
* queued on the client's AsyncQueue, and when another tab has locked the
|
|
88
|
+
* shared IndexedDB they queue behind an operation that never finishes
|
|
89
|
+
* (AGL-3428). Unbounded, the first such cycle never settled, and every
|
|
90
|
+
* later recovery request shared it forever.
|
|
91
|
+
*
|
|
92
|
+
* ## When the cycle is not enough (AGL-3428)
|
|
93
|
+
*
|
|
94
|
+
* A tab frozen partway through an IndexedDB transaction holds every store the
|
|
95
|
+
* SDK uses, and the election the cycle forces cannot run. For a client on the
|
|
96
|
+
* durable multi-tab cache the cycle is followed by the lease check in
|
|
97
|
+
* `firestore-multitab-wedge.ts`, which moves the tab to a memory cache when
|
|
98
|
+
* the lease shows the wedge.
|
|
86
99
|
*/
|
|
87
100
|
/**
|
|
88
101
|
* How long a server read or a listener's first server-confirmed snapshot may
|
|
@@ -93,6 +106,11 @@ import { type Firestore } from 'firebase/firestore';
|
|
|
93
106
|
export declare const FIRESTORE_STALL_MS = 8000;
|
|
94
107
|
/** The least time between two recoveries of the same client. */
|
|
95
108
|
export declare const STALL_RECOVERY_COOLDOWN_MS = 30000;
|
|
109
|
+
/**
|
|
110
|
+
* The longest `disableNetwork` or `enableNetwork` may take. Both finish in
|
|
111
|
+
* milliseconds on a client whose queue is moving.
|
|
112
|
+
*/
|
|
113
|
+
export declare const NETWORK_STEP_TIMEOUT_MS = 5000;
|
|
96
114
|
/** What a recovery request did. */
|
|
97
115
|
export type StallRecoveryOutcome =
|
|
98
116
|
/** The browser reports no network: the cache is the right answer. */
|
|
@@ -103,8 +121,10 @@ export type StallRecoveryOutcome =
|
|
|
103
121
|
| 'cooling'
|
|
104
122
|
/** The network was cycled. */
|
|
105
123
|
| 'recovered'
|
|
106
|
-
/** The cycle threw; the network was still re-enabled if at all possible. */
|
|
107
|
-
| 'failed'
|
|
124
|
+
/** The cycle threw or timed out; the network was still re-enabled if at all possible. */
|
|
125
|
+
| 'failed'
|
|
126
|
+
/** The multi-tab cache was wedged and this tab is moving to the memory cache. */
|
|
127
|
+
| 'fell-back';
|
|
108
128
|
export interface StallRecoveryDeps {
|
|
109
129
|
/** Take the client off the network. */
|
|
110
130
|
disable: () => Promise<void>;
|
|
@@ -116,6 +136,13 @@ export interface StallRecoveryDeps {
|
|
|
116
136
|
isVisible?: () => boolean;
|
|
117
137
|
now?: () => number;
|
|
118
138
|
cooldownMs?: number;
|
|
139
|
+
/** The bound on each of `disable` and `enable`. */
|
|
140
|
+
stepTimeoutMs?: number;
|
|
141
|
+
/**
|
|
142
|
+
* Runs after every cycle; resolves `true` when it has moved the tab off
|
|
143
|
+
* the shared cache. Only a multi-tab client has one.
|
|
144
|
+
*/
|
|
145
|
+
afterCycle?: () => Promise<boolean>;
|
|
119
146
|
}
|
|
120
147
|
export interface StallRecovery {
|
|
121
148
|
recover: () => Promise<StallRecoveryOutcome>;
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ import { disableNetwork, enableNetwork } from "firebase/firestore";
|
|
17
|
+
import { browserWedgeEscalationFor } from "./firestore-multitab-wedge.js";
|
|
17
18
|
/**
|
|
18
19
|
* RECOVERING A FIRESTORE CLIENT THAT HAS STOPPED SYNCING (AGL-3373).
|
|
19
20
|
*
|
|
@@ -82,6 +83,19 @@
|
|
|
82
83
|
* a page stalls together, and one cycle serves them all. A concurrent
|
|
83
84
|
* request shares the cycle already running.
|
|
84
85
|
* - **The network is always re-enabled**, including when disabling fails.
|
|
86
|
+
* - **Each step is bounded by {@link NETWORK_STEP_TIMEOUT_MS}.** Both calls are
|
|
87
|
+
* queued on the client's AsyncQueue, and when another tab has locked the
|
|
88
|
+
* shared IndexedDB they queue behind an operation that never finishes
|
|
89
|
+
* (AGL-3428). Unbounded, the first such cycle never settled, and every
|
|
90
|
+
* later recovery request shared it forever.
|
|
91
|
+
*
|
|
92
|
+
* ## When the cycle is not enough (AGL-3428)
|
|
93
|
+
*
|
|
94
|
+
* A tab frozen partway through an IndexedDB transaction holds every store the
|
|
95
|
+
* SDK uses, and the election the cycle forces cannot run. For a client on the
|
|
96
|
+
* durable multi-tab cache the cycle is followed by the lease check in
|
|
97
|
+
* `firestore-multitab-wedge.ts`, which moves the tab to a memory cache when
|
|
98
|
+
* the lease shows the wedge.
|
|
85
99
|
*/ /**
|
|
86
100
|
* How long a server read or a listener's first server-confirmed snapshot may
|
|
87
101
|
* take before the client is treated as stalled. A healthy console answers
|
|
@@ -89,23 +103,38 @@
|
|
|
89
103
|
* leaving a reader on a spinner for long.
|
|
90
104
|
*/ export const FIRESTORE_STALL_MS = 8000;
|
|
91
105
|
/** The least time between two recoveries of the same client. */ export const STALL_RECOVERY_COOLDOWN_MS = 30000;
|
|
106
|
+
/**
|
|
107
|
+
* The longest `disableNetwork` or `enableNetwork` may take. Both finish in
|
|
108
|
+
* milliseconds on a client whose queue is moving.
|
|
109
|
+
*/ export const NETWORK_STEP_TIMEOUT_MS = 5000;
|
|
92
110
|
const browserOnline = ()=>typeof navigator === 'undefined' || navigator.onLine !== false;
|
|
93
111
|
const documentVisible = ()=>typeof document === 'undefined' || document.visibilityState !== 'hidden';
|
|
94
112
|
/** The recovery for one client, with its guards. Pure over `deps`. */ export function createStallRecovery(deps) {
|
|
95
|
-
const { disable, enable, isOnline = browserOnline, isVisible = documentVisible, now = Date.now, cooldownMs = STALL_RECOVERY_COOLDOWN_MS } = deps;
|
|
113
|
+
const { disable, enable, isOnline = browserOnline, isVisible = documentVisible, now = Date.now, cooldownMs = STALL_RECOVERY_COOLDOWN_MS, stepTimeoutMs = NETWORK_STEP_TIMEOUT_MS, afterCycle } = deps;
|
|
96
114
|
let lastStartedAt;
|
|
97
115
|
let running;
|
|
116
|
+
/** Whether `step` settled successfully within `stepTimeoutMs`. */ const bounded = (step)=>new Promise((resolve)=>{
|
|
117
|
+
const timer = setTimeout(()=>resolve(false), stepTimeoutMs);
|
|
118
|
+
const done = (ok)=>{
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
resolve(ok);
|
|
121
|
+
};
|
|
122
|
+
try {
|
|
123
|
+
step().then(()=>done(true), ()=>done(false));
|
|
124
|
+
} catch (unused) {
|
|
125
|
+
done(false);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
98
128
|
const cycle = async ()=>{
|
|
99
129
|
let outcome = 'recovered';
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
outcome = 'failed';
|
|
130
|
+
if (!await bounded(disable)) outcome = 'failed';
|
|
131
|
+
if (!await bounded(enable)) outcome = 'failed';
|
|
132
|
+
if (afterCycle) {
|
|
133
|
+
try {
|
|
134
|
+
if (await afterCycle()) return 'fell-back';
|
|
135
|
+
} catch (unused) {
|
|
136
|
+
// The escalation is best effort; the cycle's own outcome stands.
|
|
137
|
+
}
|
|
109
138
|
}
|
|
110
139
|
return outcome;
|
|
111
140
|
};
|
|
@@ -139,7 +168,8 @@ const recoveries = new WeakMap();
|
|
|
139
168
|
if (!recovery) {
|
|
140
169
|
recovery = createStallRecovery({
|
|
141
170
|
disable: ()=>disableNetwork(firestore),
|
|
142
|
-
enable: ()=>enableNetwork(firestore)
|
|
171
|
+
enable: ()=>enableNetwork(firestore),
|
|
172
|
+
afterCycle: browserWedgeEscalationFor(firestore)
|
|
143
173
|
});
|
|
144
174
|
recoveries.set(firestore, recovery);
|
|
145
175
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/firebase/firestore-stall-recovery.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 * RECOVERING A FIRESTORE CLIENT THAT HAS STOPPED SYNCING (AGL-3373).\n *\n * ## The wedge\n *\n * The console runs the persistent cache with `persistentMultipleTabManager`\n * (`firestore-cache.ts`). Only the PRIMARY tab talks to the backend; every\n * other tab reads the shared IndexedDB cache and takes its online state from\n * a `localStorage` record the primary writes. A secondary that believes the\n * client is online answers a `getDocs` only once the primary has synced the\n * query, and raises no first snapshot for a listener whose cache is empty.\n *\n * When the primary dies without handing over — a crashed, discarded or\n * frozen tab — its \"Online\" record outlives it, and until some tab runs the\n * lease election and wins, nothing syncs. The election runs on a 4-second\n * timer inside each tab, which the browser throttles to about once a minute\n * in a hidden tab. On 2026-09-28 no live tab synced for about ten minutes:\n * the notifications page never left its loading state and a staff page sat\n * on its splash until a fresh tab (which wins the election on start) was\n * opened.\n *\n * ## The recovery, and why it is this one\n *\n * `disableNetwork` then `enableNetwork`, from the tab that noticed. Both are\n * public API, and in the multi-tab client each one calls\n * `setNetworkEnabled`, which schedules the lease election for IMMEDIATE\n * execution instead of the next (possibly throttled) timer tick. A visible\n * tab that finds the lease lapsed takes it, starts its own streams and\n * writes its own online state, which replaces the dead tab's. A tab that was\n * already primary with a silently dead stream gets fresh streams.\n *\n * Nothing is lost. Writes made meanwhile sit in the mutation queue (in\n * IndexedDB, under the persistent cache) and are sent when the network comes\n * back; the SDK's own contract for `disableNetwork` is that writes queue.\n *\n * The rejected alternatives:\n *\n * - **Clearing the persistence cache** (`terminate` + `clearIndexedDbPersistence`).\n * It deletes the mutation queue, so pending writes are lost, and\n * `waitForPendingWrites` cannot drain them first because draining is the\n * very thing that is stuck. It also refuses to run while any other tab\n * holds the database, which in a multi-tab wedge is always. And\n * `terminate` invalidates the one `Firestore` instance every hook in the\n * console holds.\n * - **Pruning the dead tab's online-state record at startup**, beside the\n * AGL-2845 prune. The SDK already ignores an online-state record at\n * startup unless its writer is an active, non-zombie client\n * (`WebStorageSharedClientState.handleOnlineStateEvent`), and a freshly\n * started tab wins the election anyway — which is why a fresh tab fixed\n * the incident. The tabs that stayed stuck were the ones ALREADY running,\n * which a startup prune never reaches.\n * - **`persistentSingleTabManager`.** It removes cross-tab dependency, but\n * every tab after the first falls back to a memory cache, so writes made\n * offline in those tabs are lost when they close. And a tab still on the\n * multi-tab build that meets a single-tab lease holder fails its queue\n * with `failed-precondition` until reloaded, so the rollout itself would\n * wedge the tabs left open across it.\n *\n * ## Guards\n *\n * - **Online only.** A browser that reports no network is correctly offline,\n * and the cache answering is the cache doing its job.\n * - **Visible only.** A hidden tab's timers are throttled and its reader is\n * not waiting; if it is stalled, it recovers the moment it is looked at.\n * - **Once per {@link STALL_RECOVERY_COOLDOWN_MS} per client.** Every list on\n * a page stalls together, and one cycle serves them all. A concurrent\n * request shares the cycle already running.\n * - **The network is always re-enabled**, including when disabling fails.\n */\n\n/**\n * How long a server read or a listener's first server-confirmed snapshot may\n * take before the client is treated as stalled. A healthy console answers\n * in well under two seconds; this leaves room for a slow network without\n * leaving a reader on a spinner for long.\n */\nexport const FIRESTORE_STALL_MS = 8_000\n\n/** The least time between two recoveries of the same client. */\nexport const STALL_RECOVERY_COOLDOWN_MS = 30_000\n\n/** What a recovery request did. */\nexport type StallRecoveryOutcome =\n /** The browser reports no network: the cache is the right answer. */\n | 'offline'\n /** The tab is hidden: nobody is waiting on it. */\n | 'hidden'\n /** A recovery ran moments ago; this request rode on it. */\n | 'cooling'\n /** The network was cycled. */\n | 'recovered'\n /** The cycle threw; the network was still re-enabled if at all possible. */\n | 'failed'\n\nexport interface StallRecoveryDeps {\n /** Take the client off the network. */\n disable: () => Promise<void>\n /** Put it back. */\n enable: () => Promise<void>\n /** `navigator.onLine`, or `true` where there is no navigator. */\n isOnline?: () => boolean\n /** Whether the document is visible, or `true` where there is none. */\n isVisible?: () => boolean\n now?: () => number\n cooldownMs?: number\n}\n\nexport interface StallRecovery {\n recover: () => Promise<StallRecoveryOutcome>\n}\n\nconst browserOnline = () =>\n typeof navigator === 'undefined' || navigator.onLine !== false\n\nconst documentVisible = () =>\n typeof document === 'undefined' || document.visibilityState !== 'hidden'\n\n/** The recovery for one client, with its guards. Pure over `deps`. */\nexport function createStallRecovery(deps: StallRecoveryDeps): StallRecovery {\n const {\n disable,\n enable,\n isOnline = browserOnline,\n isVisible = documentVisible,\n now = Date.now,\n cooldownMs = STALL_RECOVERY_COOLDOWN_MS,\n } = deps\n let lastStartedAt: number | undefined\n let running: Promise<StallRecoveryOutcome> | undefined\n\n const cycle = async (): Promise<StallRecoveryOutcome> => {\n let outcome: StallRecoveryOutcome = 'recovered'\n try {\n await disable()\n } catch {\n outcome = 'failed'\n }\n try {\n await enable()\n } catch {\n outcome = 'failed'\n }\n return outcome\n }\n\n return {\n recover: () => {\n if (running) return running\n if (!isOnline()) return Promise.resolve('offline')\n if (!isVisible()) return Promise.resolve('hidden')\n const at = now()\n if (lastStartedAt !== undefined && at - lastStartedAt < cooldownMs) {\n return Promise.resolve('cooling')\n }\n lastStartedAt = at\n running = cycle().finally(() => {\n running = undefined\n })\n return running\n },\n }\n}\n\nconst recoveries = new WeakMap<Firestore, StallRecovery>()\n\n/**\n * Recovers `firestore` if it has stalled. Safe to call from every reader\n * that notices: the guards above collapse the calls into at most one network\n * cycle per cooldown. Never rejects.\n */\nexport function recoverStalledFirestore(\n firestore: Firestore | null | undefined,\n): Promise<StallRecoveryOutcome> {\n if (!firestore || typeof firestore !== 'object') {\n return Promise.resolve('failed')\n }\n let recovery = recoveries.get(firestore)\n if (!recovery) {\n recovery = createStallRecovery({\n disable: () => disableNetwork(firestore),\n enable: () => enableNetwork(firestore),\n })\n recoveries.set(firestore, recovery)\n }\n return recovery.recover().catch(() => 'failed' as const)\n}\n\n/**\n * Arms a stall timer for one read or listen. If `disarm` has not been called\n * within `stallMs`, `onStall` runs (by default, {@link recoverStalledFirestore}).\n * A tab that is hidden when the timer fires waits until it is shown, since\n * that is when a reader is waiting again.\n *\n * Returns `disarm`, which is idempotent.\n */\nexport function armFirestoreStallWatch(\n firestore: Firestore | null | undefined,\n options: { stallMs?: number; onStall?: () => void } = {},\n): () => void {\n const { stallMs = FIRESTORE_STALL_MS } = options\n const onStall =\n options.onStall ?? (() => void recoverStalledFirestore(firestore))\n let done = false\n let onVisible: (() => void) | undefined\n const fire = () => {\n if (done) return\n if (!documentVisible() && typeof document !== 'undefined') {\n onVisible = () => {\n if (!documentVisible()) return\n document.removeEventListener('visibilitychange', onVisible!)\n onVisible = undefined\n fire()\n }\n document.addEventListener('visibilitychange', onVisible)\n return\n }\n done = true\n onStall()\n }\n const timer = setTimeout(fire, stallMs)\n return () => {\n if (done) return\n done = true\n clearTimeout(timer)\n if (onVisible && typeof document !== 'undefined') {\n document.removeEventListener('visibilitychange', onVisible)\n }\n }\n}\n"],"names":["disableNetwork","enableNetwork","FIRESTORE_STALL_MS","STALL_RECOVERY_COOLDOWN_MS","browserOnline","navigator","onLine","documentVisible","document","visibilityState","createStallRecovery","deps","disable","enable","isOnline","isVisible","now","Date","cooldownMs","lastStartedAt","running","cycle","outcome","recover","Promise","resolve","at","undefined","finally","recoveries","WeakMap","recoverStalledFirestore","firestore","recovery","get","set","catch","armFirestoreStallWatch","options","stallMs","onStall","done","onVisible","fire","removeEventListener","addEventListener","timer","setTimeout","clearTimeout"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,cAAc,EACdC,aAAa,QAER,qBAAoB;AAE3B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoEC,GAED;;;;;CAKC,GACD,OAAO,MAAMC,qBAAqB,KAAK;AAEvC,8DAA8D,GAC9D,OAAO,MAAMC,6BAA6B,MAAM;AAgChD,MAAMC,gBAAgB,IACpB,OAAOC,cAAc,eAAeA,UAAUC,MAAM,KAAK;AAE3D,MAAMC,kBAAkB,IACtB,OAAOC,aAAa,eAAeA,SAASC,eAAe,KAAK;AAElE,oEAAoE,GACpE,OAAO,SAASC,oBAAoBC,IAAuB;IACzD,MAAM,EACJC,OAAO,EACPC,MAAM,EACNC,WAAWV,aAAa,EACxBW,YAAYR,eAAe,EAC3BS,MAAMC,KAAKD,GAAG,EACdE,aAAaf,0BAA0B,EACxC,GAAGQ;IACJ,IAAIQ;IACJ,IAAIC;IAEJ,MAAMC,QAAQ;QACZ,IAAIC,UAAgC;QACpC,IAAI;YACF,MAAMV;QACR,EAAE,eAAM;YACNU,UAAU;QACZ;QACA,IAAI;YACF,MAAMT;QACR,EAAE,eAAM;YACNS,UAAU;QACZ;QACA,OAAOA;IACT;IAEA,OAAO;QACLC,SAAS;YACP,IAAIH,SAAS,OAAOA;YACpB,IAAI,CAACN,YAAY,OAAOU,QAAQC,OAAO,CAAC;YACxC,IAAI,CAACV,aAAa,OAAOS,QAAQC,OAAO,CAAC;YACzC,MAAMC,KAAKV;YACX,IAAIG,kBAAkBQ,aAAaD,KAAKP,gBAAgBD,YAAY;gBAClE,OAAOM,QAAQC,OAAO,CAAC;YACzB;YACAN,gBAAgBO;YAChBN,UAAUC,QAAQO,OAAO,CAAC;gBACxBR,UAAUO;YACZ;YACA,OAAOP;QACT;IACF;AACF;AAEA,MAAMS,aAAa,IAAIC;AAEvB;;;;CAIC,GACD,OAAO,SAASC,wBACdC,SAAuC;IAEvC,IAAI,CAACA,aAAa,OAAOA,cAAc,UAAU;QAC/C,OAAOR,QAAQC,OAAO,CAAC;IACzB;IACA,IAAIQ,WAAWJ,WAAWK,GAAG,CAACF;IAC9B,IAAI,CAACC,UAAU;QACbA,WAAWvB,oBAAoB;YAC7BE,SAAS,IAAMZ,eAAegC;YAC9BnB,QAAQ,IAAMZ,cAAc+B;QAC9B;QACAH,WAAWM,GAAG,CAACH,WAAWC;IAC5B;IACA,OAAOA,SAASV,OAAO,GAAGa,KAAK,CAAC,IAAM;AACxC;AAEA;;;;;;;CAOC,GACD,OAAO,SAASC,uBACdL,SAAuC,EACvCM,UAAsD,CAAC,CAAC;QAItDA;IAFF,MAAM,EAAEC,UAAUrC,kBAAkB,EAAE,GAAGoC;IACzC,MAAME,WACJF,mBAAAA,QAAQE,OAAO,YAAfF,mBAAoB,IAAM,KAAKP,wBAAwBC;IACzD,IAAIS,OAAO;IACX,IAAIC;IACJ,MAAMC,OAAO;QACX,IAAIF,MAAM;QACV,IAAI,CAAClC,qBAAqB,OAAOC,aAAa,aAAa;YACzDkC,YAAY;gBACV,IAAI,CAACnC,mBAAmB;gBACxBC,SAASoC,mBAAmB,CAAC,oBAAoBF;gBACjDA,YAAYf;gBACZgB;YACF;YACAnC,SAASqC,gBAAgB,CAAC,oBAAoBH;YAC9C;QACF;QACAD,OAAO;QACPD;IACF;IACA,MAAMM,QAAQC,WAAWJ,MAAMJ;IAC/B,OAAO;QACL,IAAIE,MAAM;QACVA,OAAO;QACPO,aAAaF;QACb,IAAIJ,aAAa,OAAOlC,aAAa,aAAa;YAChDA,SAASoC,mBAAmB,CAAC,oBAAoBF;QACnD;IACF;AACF"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/firebase/firestore-stall-recovery.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\nimport { browserWedgeEscalationFor } from './firestore-multitab-wedge'\n\n/**\n * RECOVERING A FIRESTORE CLIENT THAT HAS STOPPED SYNCING (AGL-3373).\n *\n * ## The wedge\n *\n * The console runs the persistent cache with `persistentMultipleTabManager`\n * (`firestore-cache.ts`). Only the PRIMARY tab talks to the backend; every\n * other tab reads the shared IndexedDB cache and takes its online state from\n * a `localStorage` record the primary writes. A secondary that believes the\n * client is online answers a `getDocs` only once the primary has synced the\n * query, and raises no first snapshot for a listener whose cache is empty.\n *\n * When the primary dies without handing over — a crashed, discarded or\n * frozen tab — its \"Online\" record outlives it, and until some tab runs the\n * lease election and wins, nothing syncs. The election runs on a 4-second\n * timer inside each tab, which the browser throttles to about once a minute\n * in a hidden tab. On 2026-09-28 no live tab synced for about ten minutes:\n * the notifications page never left its loading state and a staff page sat\n * on its splash until a fresh tab (which wins the election on start) was\n * opened.\n *\n * ## The recovery, and why it is this one\n *\n * `disableNetwork` then `enableNetwork`, from the tab that noticed. Both are\n * public API, and in the multi-tab client each one calls\n * `setNetworkEnabled`, which schedules the lease election for IMMEDIATE\n * execution instead of the next (possibly throttled) timer tick. A visible\n * tab that finds the lease lapsed takes it, starts its own streams and\n * writes its own online state, which replaces the dead tab's. A tab that was\n * already primary with a silently dead stream gets fresh streams.\n *\n * Nothing is lost. Writes made meanwhile sit in the mutation queue (in\n * IndexedDB, under the persistent cache) and are sent when the network comes\n * back; the SDK's own contract for `disableNetwork` is that writes queue.\n *\n * The rejected alternatives:\n *\n * - **Clearing the persistence cache** (`terminate` + `clearIndexedDbPersistence`).\n * It deletes the mutation queue, so pending writes are lost, and\n * `waitForPendingWrites` cannot drain them first because draining is the\n * very thing that is stuck. It also refuses to run while any other tab\n * holds the database, which in a multi-tab wedge is always. And\n * `terminate` invalidates the one `Firestore` instance every hook in the\n * console holds.\n * - **Pruning the dead tab's online-state record at startup**, beside the\n * AGL-2845 prune. The SDK already ignores an online-state record at\n * startup unless its writer is an active, non-zombie client\n * (`WebStorageSharedClientState.handleOnlineStateEvent`), and a freshly\n * started tab wins the election anyway — which is why a fresh tab fixed\n * the incident. The tabs that stayed stuck were the ones ALREADY running,\n * which a startup prune never reaches.\n * - **`persistentSingleTabManager`.** It removes cross-tab dependency, but\n * every tab after the first falls back to a memory cache, so writes made\n * offline in those tabs are lost when they close. And a tab still on the\n * multi-tab build that meets a single-tab lease holder fails its queue\n * with `failed-precondition` until reloaded, so the rollout itself would\n * wedge the tabs left open across it.\n *\n * ## Guards\n *\n * - **Online only.** A browser that reports no network is correctly offline,\n * and the cache answering is the cache doing its job.\n * - **Visible only.** A hidden tab's timers are throttled and its reader is\n * not waiting; if it is stalled, it recovers the moment it is looked at.\n * - **Once per {@link STALL_RECOVERY_COOLDOWN_MS} per client.** Every list on\n * a page stalls together, and one cycle serves them all. A concurrent\n * request shares the cycle already running.\n * - **The network is always re-enabled**, including when disabling fails.\n * - **Each step is bounded by {@link NETWORK_STEP_TIMEOUT_MS}.** Both calls are\n * queued on the client's AsyncQueue, and when another tab has locked the\n * shared IndexedDB they queue behind an operation that never finishes\n * (AGL-3428). Unbounded, the first such cycle never settled, and every\n * later recovery request shared it forever.\n *\n * ## When the cycle is not enough (AGL-3428)\n *\n * A tab frozen partway through an IndexedDB transaction holds every store the\n * SDK uses, and the election the cycle forces cannot run. For a client on the\n * durable multi-tab cache the cycle is followed by the lease check in\n * `firestore-multitab-wedge.ts`, which moves the tab to a memory cache when\n * the lease shows the wedge.\n */\n\n/**\n * How long a server read or a listener's first server-confirmed snapshot may\n * take before the client is treated as stalled. A healthy console answers\n * in well under two seconds; this leaves room for a slow network without\n * leaving a reader on a spinner for long.\n */\nexport const FIRESTORE_STALL_MS = 8_000\n\n/** The least time between two recoveries of the same client. */\nexport const STALL_RECOVERY_COOLDOWN_MS = 30_000\n\n/**\n * The longest `disableNetwork` or `enableNetwork` may take. Both finish in\n * milliseconds on a client whose queue is moving.\n */\nexport const NETWORK_STEP_TIMEOUT_MS = 5_000\n\n/** What a recovery request did. */\nexport type StallRecoveryOutcome =\n /** The browser reports no network: the cache is the right answer. */\n | 'offline'\n /** The tab is hidden: nobody is waiting on it. */\n | 'hidden'\n /** A recovery ran moments ago; this request rode on it. */\n | 'cooling'\n /** The network was cycled. */\n | 'recovered'\n /** The cycle threw or timed out; the network was still re-enabled if at all possible. */\n | 'failed'\n /** The multi-tab cache was wedged and this tab is moving to the memory cache. */\n | 'fell-back'\n\nexport interface StallRecoveryDeps {\n /** Take the client off the network. */\n disable: () => Promise<void>\n /** Put it back. */\n enable: () => Promise<void>\n /** `navigator.onLine`, or `true` where there is no navigator. */\n isOnline?: () => boolean\n /** Whether the document is visible, or `true` where there is none. */\n isVisible?: () => boolean\n now?: () => number\n cooldownMs?: number\n /** The bound on each of `disable` and `enable`. */\n stepTimeoutMs?: number\n /**\n * Runs after every cycle; resolves `true` when it has moved the tab off\n * the shared cache. Only a multi-tab client has one.\n */\n afterCycle?: () => Promise<boolean>\n}\n\nexport interface StallRecovery {\n recover: () => Promise<StallRecoveryOutcome>\n}\n\nconst browserOnline = () =>\n typeof navigator === 'undefined' || navigator.onLine !== false\n\nconst documentVisible = () =>\n typeof document === 'undefined' || document.visibilityState !== 'hidden'\n\n/** The recovery for one client, with its guards. Pure over `deps`. */\nexport function createStallRecovery(deps: StallRecoveryDeps): StallRecovery {\n const {\n disable,\n enable,\n isOnline = browserOnline,\n isVisible = documentVisible,\n now = Date.now,\n cooldownMs = STALL_RECOVERY_COOLDOWN_MS,\n stepTimeoutMs = NETWORK_STEP_TIMEOUT_MS,\n afterCycle,\n } = deps\n let lastStartedAt: number | undefined\n let running: Promise<StallRecoveryOutcome> | undefined\n\n /** Whether `step` settled successfully within `stepTimeoutMs`. */\n const bounded = (step: () => Promise<void>): Promise<boolean> =>\n new Promise((resolve) => {\n const timer = setTimeout(() => resolve(false), stepTimeoutMs)\n const done = (ok: boolean) => {\n clearTimeout(timer)\n resolve(ok)\n }\n try {\n step().then(\n () => done(true),\n () => done(false),\n )\n } catch {\n done(false)\n }\n })\n\n const cycle = async (): Promise<StallRecoveryOutcome> => {\n let outcome: StallRecoveryOutcome = 'recovered'\n if (!(await bounded(disable))) outcome = 'failed'\n if (!(await bounded(enable))) outcome = 'failed'\n if (afterCycle) {\n try {\n if (await afterCycle()) return 'fell-back'\n } catch {\n // The escalation is best effort; the cycle's own outcome stands.\n }\n }\n return outcome\n }\n\n return {\n recover: () => {\n if (running) return running\n if (!isOnline()) return Promise.resolve('offline')\n if (!isVisible()) return Promise.resolve('hidden')\n const at = now()\n if (lastStartedAt !== undefined && at - lastStartedAt < cooldownMs) {\n return Promise.resolve('cooling')\n }\n lastStartedAt = at\n running = cycle().finally(() => {\n running = undefined\n })\n return running\n },\n }\n}\n\nconst recoveries = new WeakMap<Firestore, StallRecovery>()\n\n/**\n * Recovers `firestore` if it has stalled. Safe to call from every reader\n * that notices: the guards above collapse the calls into at most one network\n * cycle per cooldown. Never rejects.\n */\nexport function recoverStalledFirestore(\n firestore: Firestore | null | undefined,\n): Promise<StallRecoveryOutcome> {\n if (!firestore || typeof firestore !== 'object') {\n return Promise.resolve('failed')\n }\n let recovery = recoveries.get(firestore)\n if (!recovery) {\n recovery = createStallRecovery({\n disable: () => disableNetwork(firestore),\n enable: () => enableNetwork(firestore),\n afterCycle: browserWedgeEscalationFor(firestore),\n })\n recoveries.set(firestore, recovery)\n }\n return recovery.recover().catch(() => 'failed' as const)\n}\n\n/**\n * Arms a stall timer for one read or listen. If `disarm` has not been called\n * within `stallMs`, `onStall` runs (by default, {@link recoverStalledFirestore}).\n * A tab that is hidden when the timer fires waits until it is shown, since\n * that is when a reader is waiting again.\n *\n * Returns `disarm`, which is idempotent.\n */\nexport function armFirestoreStallWatch(\n firestore: Firestore | null | undefined,\n options: { stallMs?: number; onStall?: () => void } = {},\n): () => void {\n const { stallMs = FIRESTORE_STALL_MS } = options\n const onStall =\n options.onStall ?? (() => void recoverStalledFirestore(firestore))\n let done = false\n let onVisible: (() => void) | undefined\n const fire = () => {\n if (done) return\n if (!documentVisible() && typeof document !== 'undefined') {\n onVisible = () => {\n if (!documentVisible()) return\n document.removeEventListener('visibilitychange', onVisible!)\n onVisible = undefined\n fire()\n }\n document.addEventListener('visibilitychange', onVisible)\n return\n }\n done = true\n onStall()\n }\n const timer = setTimeout(fire, stallMs)\n return () => {\n if (done) return\n done = true\n clearTimeout(timer)\n if (onVisible && typeof document !== 'undefined') {\n document.removeEventListener('visibilitychange', onVisible)\n }\n }\n}\n"],"names":["disableNetwork","enableNetwork","browserWedgeEscalationFor","FIRESTORE_STALL_MS","STALL_RECOVERY_COOLDOWN_MS","NETWORK_STEP_TIMEOUT_MS","browserOnline","navigator","onLine","documentVisible","document","visibilityState","createStallRecovery","deps","disable","enable","isOnline","isVisible","now","Date","cooldownMs","stepTimeoutMs","afterCycle","lastStartedAt","running","bounded","step","Promise","resolve","timer","setTimeout","done","ok","clearTimeout","then","cycle","outcome","recover","at","undefined","finally","recoveries","WeakMap","recoverStalledFirestore","firestore","recovery","get","set","catch","armFirestoreStallWatch","options","stallMs","onStall","onVisible","fire","removeEventListener","addEventListener"],"mappings":"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"}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { type PluginContainerKind } from '@aglyn/aglyn/plugin-manager/plugin-containers';
|
|
2
|
+
/**
|
|
3
|
+
* How many containers a picker offers.
|
|
4
|
+
*
|
|
5
|
+
* The same fifty an owner's container table draws, so a container a merchant
|
|
6
|
+
* can see in its list is one they can file a record under. A picker with a
|
|
7
|
+
* larger window would offer containers the list they came from does not
|
|
8
|
+
* show; a smaller one would hide containers that are plainly there.
|
|
9
|
+
*/
|
|
10
|
+
export declare const CONTAINER_OPTION_CEILING = 50;
|
|
11
|
+
/** One container, as much of it as a picker needs. */
|
|
12
|
+
export interface ContainerOption {
|
|
13
|
+
value: string;
|
|
14
|
+
label: string;
|
|
15
|
+
/**
|
|
16
|
+
* The sites the container is placed on, `null` when it is on every site.
|
|
17
|
+
* Read from its `visibleTo`; an unscoped container is on no site (`[]`).
|
|
18
|
+
*/
|
|
19
|
+
siteIds?: string[] | null;
|
|
20
|
+
}
|
|
21
|
+
export interface ContainerOptions {
|
|
22
|
+
options: ContainerOption[];
|
|
23
|
+
/** The org holds more matching containers than the ceiling offers. */
|
|
24
|
+
truncated: boolean;
|
|
25
|
+
/** The read has answered — false while it is still settling or disabled. */
|
|
26
|
+
ready: boolean;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The picker's shape, from a ceilinged read of one kind's containers.
|
|
30
|
+
*
|
|
31
|
+
* One mapping for both hooks below, so a container reads the same on a
|
|
32
|
+
* site's picker and on the organization's. The name is read from the field
|
|
33
|
+
* the owner declares, falling back to the id.
|
|
34
|
+
*/
|
|
35
|
+
export declare function containerOptionsFrom(data: ReadonlyArray<Record<string, unknown>> | null | undefined, declared: Pick<PluginContainerKind, 'nameField'> | null, ready: boolean): ContainerOptions;
|
|
36
|
+
/**
|
|
37
|
+
* THE CONTAINERS OF ONE KIND A SITE OFFERS, for a picker on some other
|
|
38
|
+
* record's page.
|
|
39
|
+
*
|
|
40
|
+
* Lives here rather than in the plugin that keeps the kind because the
|
|
41
|
+
* console app may not import a feature plugin — a screen's detail page is an
|
|
42
|
+
* app route — and the form, contact and lead surfaces that file records are
|
|
43
|
+
* other plugins again. The kind is declared by its owner
|
|
44
|
+
* (`plugin-manager/plugin-containers.ts`); this reads where the declaration
|
|
45
|
+
* says, and a kind no plugin keeps settles as ready with nothing to offer.
|
|
46
|
+
*
|
|
47
|
+
* ## The org's containers, as this site sees them
|
|
48
|
+
*
|
|
49
|
+
* Containers belong to the organization (`orgs/{orgId}/{orgCollection}`),
|
|
50
|
+
* and each is placed on some of its sites by its `visibleTo`. A site's
|
|
51
|
+
* picker offers the ones placed on it: `'org'` (every site) or
|
|
52
|
+
* `host:{hostId}`, which is `scopeTokensForHost`. The clause is also what
|
|
53
|
+
* makes the read provable for a collaborator scoped to this site — the rules
|
|
54
|
+
* evaluate `visibleTo` per document on a list, and an unfiltered query is
|
|
55
|
+
* refused whole rather than narrowed.
|
|
56
|
+
*
|
|
57
|
+
* The org comes from the `hostIndex` lookup. Nothing is read until it
|
|
58
|
+
* answers, and a host with no org settles as ready with nothing.
|
|
59
|
+
*
|
|
60
|
+
* ## It is OFF unless a caller asks
|
|
61
|
+
*
|
|
62
|
+
* `enabled` defaults to false. The picker this feeds sits on a record's own
|
|
63
|
+
* page beside fields a reader came for; charging every open of a form, a
|
|
64
|
+
* screen or a contact for fifty container documents they may not touch is
|
|
65
|
+
* the read-on-mount this console refuses. The callers turn it on when their
|
|
66
|
+
* editing surface opens.
|
|
67
|
+
*
|
|
68
|
+
* ## Ordered on the document name
|
|
69
|
+
*
|
|
70
|
+
* `collectionCeiling`, for the reason it exists: a container's own dates are
|
|
71
|
+
* optional, so ordering on one would not mis-sort the picker, it would DROP
|
|
72
|
+
* the containers that have none. The labels are sorted by name here, over the
|
|
73
|
+
* whole ceiling rather than a slice of it, which is the one case sorting a
|
|
74
|
+
* window is honest. It is also the one ordering the automatic single-field
|
|
75
|
+
* index on `visibleTo` serves beside `array-contains-any`, so the query
|
|
76
|
+
* needs no composite index.
|
|
77
|
+
*/
|
|
78
|
+
export declare function useSiteContainerOptions(kind: string, hostId: string | undefined, options?: {
|
|
79
|
+
enabled?: boolean;
|
|
80
|
+
}): ContainerOptions;
|
|
81
|
+
/**
|
|
82
|
+
* THE ORGANIZATION'S CONTAINERS OF ONE KIND, for a picker on an org-level
|
|
83
|
+
* record — a lead on the org's Leads page, an outbound sequence.
|
|
84
|
+
*
|
|
85
|
+
* Every live container, whichever sites it is placed on: the records these
|
|
86
|
+
* pickers file belong to the organization, and so does the container. The
|
|
87
|
+
* read is unfiltered because the surfaces that call it are the org-level
|
|
88
|
+
* hubs, whose readers are org-wide members — the rules admit them to every
|
|
89
|
+
* container, and a `visibleTo` clause would only need the org's whole site
|
|
90
|
+
* list and still miss a site it did not carry. Each option carries its
|
|
91
|
+
* `siteIds`, for a caller that wants to say where a container runs.
|
|
92
|
+
*
|
|
93
|
+
* Off unless a caller asks, and ceilinged and ordered, for the reasons
|
|
94
|
+
* {@link useSiteContainerOptions} gives.
|
|
95
|
+
*/
|
|
96
|
+
export declare function useOrgContainerOptions(kind: string, orgId: string | null | undefined, options?: {
|
|
97
|
+
enabled?: boolean;
|
|
98
|
+
}): ContainerOptions;
|
|
@@ -0,0 +1,174 @@
|
|
|
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
|
+
*/ 'use client';
|
|
17
|
+
import { hostIdsFromScope, isOrgWideScope, scopeTokensForHost } from "@aglyn/aglyn";
|
|
18
|
+
import { pluginContainerKind } from "@aglyn/aglyn/plugin-manager/plugin-containers";
|
|
19
|
+
import { collection, query, where } from "firebase/firestore";
|
|
20
|
+
import { useMemo } from "react";
|
|
21
|
+
import { collectionCeiling } from "./host-collection-queries.js";
|
|
22
|
+
import { useFirestore } from "./firebase/firebase-services.js";
|
|
23
|
+
import { useFirestoreCollection } from "./use-firestore-collection.js";
|
|
24
|
+
import { useHostOrgIdState } from "./use-host-org-id.js";
|
|
25
|
+
/**
|
|
26
|
+
* How many containers a picker offers.
|
|
27
|
+
*
|
|
28
|
+
* The same fifty an owner's container table draws, so a container a merchant
|
|
29
|
+
* can see in its list is one they can file a record under. A picker with a
|
|
30
|
+
* larger window would offer containers the list they came from does not
|
|
31
|
+
* show; a smaller one would hide containers that are plainly there.
|
|
32
|
+
*/ export const CONTAINER_OPTION_CEILING = 50;
|
|
33
|
+
const NO_ROWS = Object.freeze([]);
|
|
34
|
+
/**
|
|
35
|
+
* The picker's shape, from a ceilinged read of one kind's containers.
|
|
36
|
+
*
|
|
37
|
+
* One mapping for both hooks below, so a container reads the same on a
|
|
38
|
+
* site's picker and on the organization's. The name is read from the field
|
|
39
|
+
* the owner declares, falling back to the id.
|
|
40
|
+
*/ export function containerOptionsFrom(data, declared, ready) {
|
|
41
|
+
var _ref;
|
|
42
|
+
const rows = declared ? data != null ? data : [] : [];
|
|
43
|
+
const truncated = rows.length > CONTAINER_OPTION_CEILING;
|
|
44
|
+
const nameField = (_ref = declared == null ? void 0 : declared.nameField) != null ? _ref : '';
|
|
45
|
+
const live = rows.slice(0, CONTAINER_OPTION_CEILING)// A container its owner retired is not one a record may be filed under.
|
|
46
|
+
// The owner's own table filters the same field for the same reason.
|
|
47
|
+
.filter((row)=>!row['deletedAt']);
|
|
48
|
+
return {
|
|
49
|
+
options: live.map((row)=>{
|
|
50
|
+
var _row_nameField;
|
|
51
|
+
const visibleTo = Array.isArray(row['visibleTo']) ? row['visibleTo'] : [];
|
|
52
|
+
return {
|
|
53
|
+
value: String(row['$id']),
|
|
54
|
+
label: String((_row_nameField = row[nameField]) != null ? _row_nameField : row['$id']),
|
|
55
|
+
siteIds: isOrgWideScope(visibleTo) ? null : hostIdsFromScope(visibleTo)
|
|
56
|
+
};
|
|
57
|
+
}).sort((a, b)=>a.label.localeCompare(b.label)),
|
|
58
|
+
truncated,
|
|
59
|
+
ready
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* THE CONTAINERS OF ONE KIND A SITE OFFERS, for a picker on some other
|
|
64
|
+
* record's page.
|
|
65
|
+
*
|
|
66
|
+
* Lives here rather than in the plugin that keeps the kind because the
|
|
67
|
+
* console app may not import a feature plugin — a screen's detail page is an
|
|
68
|
+
* app route — and the form, contact and lead surfaces that file records are
|
|
69
|
+
* other plugins again. The kind is declared by its owner
|
|
70
|
+
* (`plugin-manager/plugin-containers.ts`); this reads where the declaration
|
|
71
|
+
* says, and a kind no plugin keeps settles as ready with nothing to offer.
|
|
72
|
+
*
|
|
73
|
+
* ## The org's containers, as this site sees them
|
|
74
|
+
*
|
|
75
|
+
* Containers belong to the organization (`orgs/{orgId}/{orgCollection}`),
|
|
76
|
+
* and each is placed on some of its sites by its `visibleTo`. A site's
|
|
77
|
+
* picker offers the ones placed on it: `'org'` (every site) or
|
|
78
|
+
* `host:{hostId}`, which is `scopeTokensForHost`. The clause is also what
|
|
79
|
+
* makes the read provable for a collaborator scoped to this site — the rules
|
|
80
|
+
* evaluate `visibleTo` per document on a list, and an unfiltered query is
|
|
81
|
+
* refused whole rather than narrowed.
|
|
82
|
+
*
|
|
83
|
+
* The org comes from the `hostIndex` lookup. Nothing is read until it
|
|
84
|
+
* answers, and a host with no org settles as ready with nothing.
|
|
85
|
+
*
|
|
86
|
+
* ## It is OFF unless a caller asks
|
|
87
|
+
*
|
|
88
|
+
* `enabled` defaults to false. The picker this feeds sits on a record's own
|
|
89
|
+
* page beside fields a reader came for; charging every open of a form, a
|
|
90
|
+
* screen or a contact for fifty container documents they may not touch is
|
|
91
|
+
* the read-on-mount this console refuses. The callers turn it on when their
|
|
92
|
+
* editing surface opens.
|
|
93
|
+
*
|
|
94
|
+
* ## Ordered on the document name
|
|
95
|
+
*
|
|
96
|
+
* `collectionCeiling`, for the reason it exists: a container's own dates are
|
|
97
|
+
* optional, so ordering on one would not mis-sort the picker, it would DROP
|
|
98
|
+
* the containers that have none. The labels are sorted by name here, over the
|
|
99
|
+
* whole ceiling rather than a slice of it, which is the one case sorting a
|
|
100
|
+
* window is honest. It is also the one ordering the automatic single-field
|
|
101
|
+
* index on `visibleTo` serves beside `array-contains-any`, so the query
|
|
102
|
+
* needs no composite index.
|
|
103
|
+
*/ export function useSiteContainerOptions(kind, hostId, options) {
|
|
104
|
+
var _ref, _ref1;
|
|
105
|
+
const declared = useMemo(()=>pluginContainerKind(kind), [
|
|
106
|
+
kind
|
|
107
|
+
]);
|
|
108
|
+
const enabled = (_ref = options == null ? void 0 : options.enabled) != null ? _ref : false;
|
|
109
|
+
const firestore = useFirestore();
|
|
110
|
+
const org = useHostOrgIdState(enabled && declared ? hostId : undefined);
|
|
111
|
+
const orgId = org.orgId;
|
|
112
|
+
const collectionName = (_ref1 = declared == null ? void 0 : declared.orgCollection) != null ? _ref1 : '';
|
|
113
|
+
// Memoised: a listener dependency, and a fresh array each render would
|
|
114
|
+
// reopen the subscription every time.
|
|
115
|
+
const tokens = useMemo(()=>hostId ? scopeTokensForHost(hostId) : null, [
|
|
116
|
+
hostId
|
|
117
|
+
]);
|
|
118
|
+
const { data, status } = useFirestoreCollection(()=>enabled && collectionName && hostId && orgId && tokens ? collectionCeiling(query(collection(firestore, 'orgs', orgId, collectionName), where('visibleTo', 'array-contains-any', tokens)), CONTAINER_OPTION_CEILING) : null, [
|
|
119
|
+
firestore,
|
|
120
|
+
hostId,
|
|
121
|
+
orgId,
|
|
122
|
+
tokens,
|
|
123
|
+
enabled,
|
|
124
|
+
collectionName
|
|
125
|
+
], {
|
|
126
|
+
idField: '$id'
|
|
127
|
+
});
|
|
128
|
+
const ready = enabled && Boolean(hostId) && (!declared || org.loaded && (orgId ? status !== 'loading' : true));
|
|
129
|
+
return useMemo(()=>containerOptionsFrom(orgId ? data : NO_ROWS, declared, ready), [
|
|
130
|
+
data,
|
|
131
|
+
orgId,
|
|
132
|
+
declared,
|
|
133
|
+
ready
|
|
134
|
+
]);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* THE ORGANIZATION'S CONTAINERS OF ONE KIND, for a picker on an org-level
|
|
138
|
+
* record — a lead on the org's Leads page, an outbound sequence.
|
|
139
|
+
*
|
|
140
|
+
* Every live container, whichever sites it is placed on: the records these
|
|
141
|
+
* pickers file belong to the organization, and so does the container. The
|
|
142
|
+
* read is unfiltered because the surfaces that call it are the org-level
|
|
143
|
+
* hubs, whose readers are org-wide members — the rules admit them to every
|
|
144
|
+
* container, and a `visibleTo` clause would only need the org's whole site
|
|
145
|
+
* list and still miss a site it did not carry. Each option carries its
|
|
146
|
+
* `siteIds`, for a caller that wants to say where a container runs.
|
|
147
|
+
*
|
|
148
|
+
* Off unless a caller asks, and ceilinged and ordered, for the reasons
|
|
149
|
+
* {@link useSiteContainerOptions} gives.
|
|
150
|
+
*/ export function useOrgContainerOptions(kind, orgId, options) {
|
|
151
|
+
var _ref, _ref1;
|
|
152
|
+
const declared = useMemo(()=>pluginContainerKind(kind), [
|
|
153
|
+
kind
|
|
154
|
+
]);
|
|
155
|
+
const enabled = (_ref = options == null ? void 0 : options.enabled) != null ? _ref : false;
|
|
156
|
+
const firestore = useFirestore();
|
|
157
|
+
const collectionName = (_ref1 = declared == null ? void 0 : declared.orgCollection) != null ? _ref1 : '';
|
|
158
|
+
const { data, status } = useFirestoreCollection(()=>enabled && collectionName && orgId ? collectionCeiling(collection(firestore, 'orgs', orgId, collectionName), CONTAINER_OPTION_CEILING) : null, [
|
|
159
|
+
firestore,
|
|
160
|
+
orgId,
|
|
161
|
+
enabled,
|
|
162
|
+
collectionName
|
|
163
|
+
], {
|
|
164
|
+
idField: '$id'
|
|
165
|
+
});
|
|
166
|
+
const ready = enabled && Boolean(orgId) && (!declared || status !== 'loading');
|
|
167
|
+
return useMemo(()=>containerOptionsFrom(data, declared, ready), [
|
|
168
|
+
data,
|
|
169
|
+
declared,
|
|
170
|
+
ready
|
|
171
|
+
]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
//# sourceMappingURL=use-container-options.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-container-options.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 {\n hostIdsFromScope,\n isOrgWideScope,\n scopeTokensForHost,\n} from '@aglyn/aglyn'\nimport {\n pluginContainerKind,\n type PluginContainerKind,\n} from '@aglyn/aglyn/plugin-manager/plugin-containers'\nimport { collection, query, where } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { collectionCeiling } from './host-collection-queries'\nimport { useFirestore } from './firebase/firebase-services'\nimport { useFirestoreCollection } from './use-firestore-collection'\nimport { useHostOrgIdState } from './use-host-org-id'\n\n/**\n * How many containers a picker offers.\n *\n * The same fifty an owner's container table draws, so a container a merchant\n * can see in its list is one they can file a record under. A picker with a\n * larger window would offer containers the list they came from does not\n * show; a smaller one would hide containers that are plainly there.\n */\nexport const CONTAINER_OPTION_CEILING = 50\n\nconst NO_ROWS: ReadonlyArray<Record<string, unknown>> = Object.freeze([])\n\n/** One container, as much of it as a picker needs. */\nexport interface ContainerOption {\n value: string\n label: string\n /**\n * The sites the container is placed on, `null` when it is on every site.\n * Read from its `visibleTo`; an unscoped container is on no site (`[]`).\n */\n siteIds?: string[] | null\n}\n\nexport interface ContainerOptions {\n options: ContainerOption[]\n /** The org holds more matching containers than the ceiling offers. */\n truncated: boolean\n /** The read has answered — false while it is still settling or disabled. */\n ready: boolean\n}\n\n/**\n * The picker's shape, from a ceilinged read of one kind's containers.\n *\n * One mapping for both hooks below, so a container reads the same on a\n * site's picker and on the organization's. The name is read from the field\n * the owner declares, falling back to the id.\n */\nexport function containerOptionsFrom(\n data: ReadonlyArray<Record<string, unknown>> | null | undefined,\n declared: Pick<PluginContainerKind, 'nameField'> | null,\n ready: boolean,\n): ContainerOptions {\n const rows = declared ? (data ?? []) : []\n const truncated = rows.length > CONTAINER_OPTION_CEILING\n const nameField = declared?.nameField ?? ''\n const live = rows\n .slice(0, CONTAINER_OPTION_CEILING)\n // A container its owner retired is not one a record may be filed under.\n // The owner's own table filters the same field for the same reason.\n .filter((row) => !row['deletedAt'])\n return {\n options: live\n .map((row) => {\n const visibleTo = Array.isArray(row['visibleTo'])\n ? (row['visibleTo'] as string[])\n : []\n return {\n value: String(row['$id']),\n label: String(row[nameField] ?? row['$id']),\n siteIds: isOrgWideScope(visibleTo)\n ? null\n : hostIdsFromScope(visibleTo),\n }\n })\n .sort((a, b) => a.label.localeCompare(b.label)),\n truncated,\n ready,\n }\n}\n\n/**\n * THE CONTAINERS OF ONE KIND A SITE OFFERS, for a picker on some other\n * record's page.\n *\n * Lives here rather than in the plugin that keeps the kind because the\n * console app may not import a feature plugin — a screen's detail page is an\n * app route — and the form, contact and lead surfaces that file records are\n * other plugins again. The kind is declared by its owner\n * (`plugin-manager/plugin-containers.ts`); this reads where the declaration\n * says, and a kind no plugin keeps settles as ready with nothing to offer.\n *\n * ## The org's containers, as this site sees them\n *\n * Containers belong to the organization (`orgs/{orgId}/{orgCollection}`),\n * and each is placed on some of its sites by its `visibleTo`. A site's\n * picker offers the ones placed on it: `'org'` (every site) or\n * `host:{hostId}`, which is `scopeTokensForHost`. The clause is also what\n * makes the read provable for a collaborator scoped to this site — the rules\n * evaluate `visibleTo` per document on a list, and an unfiltered query is\n * refused whole rather than narrowed.\n *\n * The org comes from the `hostIndex` lookup. Nothing is read until it\n * answers, and a host with no org settles as ready with nothing.\n *\n * ## It is OFF unless a caller asks\n *\n * `enabled` defaults to false. The picker this feeds sits on a record's own\n * page beside fields a reader came for; charging every open of a form, a\n * screen or a contact for fifty container documents they may not touch is\n * the read-on-mount this console refuses. The callers turn it on when their\n * editing surface opens.\n *\n * ## Ordered on the document name\n *\n * `collectionCeiling`, for the reason it exists: a container's own dates are\n * optional, so ordering on one would not mis-sort the picker, it would DROP\n * the containers that have none. The labels are sorted by name here, over the\n * whole ceiling rather than a slice of it, which is the one case sorting a\n * window is honest. It is also the one ordering the automatic single-field\n * index on `visibleTo` serves beside `array-contains-any`, so the query\n * needs no composite index.\n */\nexport function useSiteContainerOptions(\n kind: string,\n hostId: string | undefined,\n options?: { enabled?: boolean },\n): ContainerOptions {\n const declared = useMemo(() => pluginContainerKind(kind), [kind])\n const enabled = options?.enabled ?? false\n const firestore = useFirestore()\n const org = useHostOrgIdState(enabled && declared ? hostId : undefined)\n const orgId = org.orgId\n const collectionName = declared?.orgCollection ?? ''\n // Memoised: a listener dependency, and a fresh array each render would\n // reopen the subscription every time.\n const tokens = useMemo(\n () => (hostId ? scopeTokensForHost(hostId) : null),\n [hostId],\n )\n const { data, status } = useFirestoreCollection<Record<string, unknown>>(\n () =>\n enabled && collectionName && hostId && orgId && tokens\n ? collectionCeiling(\n query(\n collection(firestore, 'orgs', orgId, collectionName),\n where('visibleTo', 'array-contains-any', tokens),\n ),\n CONTAINER_OPTION_CEILING,\n )\n : null,\n [firestore, hostId, orgId, tokens, enabled, collectionName],\n { idField: '$id' },\n )\n\n const ready =\n enabled &&\n Boolean(hostId) &&\n (!declared || (org.loaded && (orgId ? status !== 'loading' : true)))\n return useMemo(\n () => containerOptionsFrom(orgId ? data : NO_ROWS, declared, ready),\n [data, orgId, declared, ready],\n )\n}\n\n/**\n * THE ORGANIZATION'S CONTAINERS OF ONE KIND, for a picker on an org-level\n * record — a lead on the org's Leads page, an outbound sequence.\n *\n * Every live container, whichever sites it is placed on: the records these\n * pickers file belong to the organization, and so does the container. The\n * read is unfiltered because the surfaces that call it are the org-level\n * hubs, whose readers are org-wide members — the rules admit them to every\n * container, and a `visibleTo` clause would only need the org's whole site\n * list and still miss a site it did not carry. Each option carries its\n * `siteIds`, for a caller that wants to say where a container runs.\n *\n * Off unless a caller asks, and ceilinged and ordered, for the reasons\n * {@link useSiteContainerOptions} gives.\n */\nexport function useOrgContainerOptions(\n kind: string,\n orgId: string | null | undefined,\n options?: { enabled?: boolean },\n): ContainerOptions {\n const declared = useMemo(() => pluginContainerKind(kind), [kind])\n const enabled = options?.enabled ?? false\n const firestore = useFirestore()\n const collectionName = declared?.orgCollection ?? ''\n const { data, status } = useFirestoreCollection<Record<string, unknown>>(\n () =>\n enabled && collectionName && orgId\n ? collectionCeiling(\n collection(firestore, 'orgs', orgId, collectionName),\n CONTAINER_OPTION_CEILING,\n )\n : null,\n [firestore, orgId, enabled, collectionName],\n { idField: '$id' },\n )\n\n const ready = enabled && Boolean(orgId) && (!declared || status !== 'loading')\n return useMemo(() => containerOptionsFrom(data, declared, ready), [data, declared, ready])\n}\n"],"names":["hostIdsFromScope","isOrgWideScope","scopeTokensForHost","pluginContainerKind","collection","query","where","useMemo","collectionCeiling","useFirestore","useFirestoreCollection","useHostOrgIdState","CONTAINER_OPTION_CEILING","NO_ROWS","Object","freeze","containerOptionsFrom","data","declared","ready","rows","truncated","length","nameField","live","slice","filter","row","options","map","visibleTo","Array","isArray","value","String","label","siteIds","sort","a","b","localeCompare","useSiteContainerOptions","kind","hostId","enabled","firestore","org","undefined","orgId","collectionName","orgCollection","tokens","status","idField","Boolean","loaded","useOrgContainerOptions"],"mappings":"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"}
|
|
@@ -0,0 +1,52 @@
|
|
|
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 * as Aglyn from '@aglyn/aglyn';
|
|
18
|
+
import type { DocumentReference } from 'firebase/firestore';
|
|
19
|
+
import { type FirestoreDocOptions } from './firebase/firebase-services';
|
|
20
|
+
/**
|
|
21
|
+
* A plugin document's working version, at
|
|
22
|
+
* `hosts/{hostId}/{collection}/{docId}/versions/{versionId}` — the storage a
|
|
23
|
+
* kind declared in `besignerDocuments` keeps (`plugin-manager/
|
|
24
|
+
* besigner-documents.ts`).
|
|
25
|
+
*
|
|
26
|
+
* Compressed at rest, and carrying the same asymmetry a component version
|
|
27
|
+
* does: the PUBLISHED tree lives on the parent document, because a placed
|
|
28
|
+
* document has to resolve on the hot path of a page render and one
|
|
29
|
+
* collection query reaches every one on the site. These documents are
|
|
30
|
+
* editing history — the besigner's draft surface — and nothing renders from
|
|
31
|
+
* them.
|
|
32
|
+
*/
|
|
33
|
+
export interface HostDocumentVersion {
|
|
34
|
+
displayName?: string;
|
|
35
|
+
/** The node a placed copy grafts from; absent on a tree never published. */
|
|
36
|
+
rootId?: Aglyn.NodeId;
|
|
37
|
+
nodes?: Record<Aglyn.NodeId, Aglyn.AglynNodeSchema>;
|
|
38
|
+
updatedAt?: unknown;
|
|
39
|
+
}
|
|
40
|
+
export interface HostDocumentVersionIds {
|
|
41
|
+
hostId: string;
|
|
42
|
+
/** The subcollection under `hosts/{hostId}` the document lives in. */
|
|
43
|
+
collection: string;
|
|
44
|
+
docId: string;
|
|
45
|
+
versionId: string;
|
|
46
|
+
}
|
|
47
|
+
export declare const useHostDocumentVersionRef: ({ hostId, collection, docId, versionId, }: HostDocumentVersionIds) => DocumentReference<HostDocumentVersion>;
|
|
48
|
+
export declare const useHostDocumentVersion: (ids: HostDocumentVersionIds, options?: FirestoreDocOptions<HostDocumentVersion>) => {
|
|
49
|
+
doc: import("./firebase/firebase-services").ObservableStatus<HostDocumentVersion>;
|
|
50
|
+
setDoc: import("./helpers/use-modify-doc-callback").ModifyDocCallback<HostDocumentVersion>;
|
|
51
|
+
};
|
|
52
|
+
export default useHostDocumentVersion;
|
|
@@ -23,19 +23,9 @@ import { Timestamp } from "@aglyn/shared-util-timestamp";
|
|
|
23
23
|
import { Bytes, doc } from "firebase/firestore";
|
|
24
24
|
import { useFirestore } from "./firebase/firebase-services.js";
|
|
25
25
|
import useDoc from "./helpers/use-doc.js";
|
|
26
|
-
|
|
27
|
-
* A form's working version, at
|
|
28
|
-
* `hosts/{hostId}/forms/{formId}/versions/{versionId}`.
|
|
29
|
-
*
|
|
30
|
-
* Compressed at rest, and carrying the same asymmetry a component version
|
|
31
|
-
* does: the PUBLISHED tree lives on the parent `forms/{formId}` document,
|
|
32
|
-
* because a placed form has to resolve on the hot path of a page render and
|
|
33
|
-
* one collection query reaches every form on the site. These documents are
|
|
34
|
-
* editing history — the besigner's draft surface — and nothing renders from
|
|
35
|
-
* them.
|
|
36
|
-
*/ export const useFormVersionRef = ({ hostId, formId, versionId })=>{
|
|
26
|
+
export const useHostDocumentVersionRef = ({ hostId, collection, docId, versionId })=>{
|
|
37
27
|
const firestore = useFirestore();
|
|
38
|
-
const ref = doc(firestore, 'hosts', hostId,
|
|
28
|
+
const ref = doc(firestore, 'hosts', hostId, collection, docId, 'versions', versionId);
|
|
39
29
|
return ref.withConverter({
|
|
40
30
|
toFirestore (data) {
|
|
41
31
|
const { $id } = data, rest = _object_without_properties_loose(data, [
|
|
@@ -65,9 +55,9 @@ import useDoc from "./helpers/use-doc.js";
|
|
|
65
55
|
}
|
|
66
56
|
});
|
|
67
57
|
};
|
|
68
|
-
export const
|
|
69
|
-
return useDoc(
|
|
58
|
+
export const useHostDocumentVersion = (ids, options)=>{
|
|
59
|
+
return useDoc(useHostDocumentVersionRef(ids), options);
|
|
70
60
|
};
|
|
71
|
-
export default
|
|
61
|
+
export default useHostDocumentVersion;
|
|
72
62
|
|
|
73
|
-
//# sourceMappingURL=use-
|
|
63
|
+
//# sourceMappingURL=use-host-document-version.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-host-document-version.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\n// A TYPE-only namespace: every `Aglyn.` reference below is a type, and held\n// as a VALUE the namespace pins every module the barrel reaches into the\n// console shell that renders these hooks. `import type` erases instead.\nimport type * as Aglyn from '@aglyn/aglyn'\nimport { compress, decompress } from '@aglyn/aglyn'\nimport { Timestamp } from '@aglyn/shared-util-timestamp'\nimport type { DocumentReference } from 'firebase/firestore'\nimport { Bytes, doc } from 'firebase/firestore'\nimport {\n useFirestore,\n type FirestoreDocOptions,\n} from './firebase/firebase-services'\nimport useDoc from './helpers/use-doc'\n\n/**\n * A plugin document's working version, at\n * `hosts/{hostId}/{collection}/{docId}/versions/{versionId}` — the storage a\n * kind declared in `besignerDocuments` keeps (`plugin-manager/\n * besigner-documents.ts`).\n *\n * Compressed at rest, and carrying the same asymmetry a component version\n * does: the PUBLISHED tree lives on the parent document, because a placed\n * document has to resolve on the hot path of a page render and one\n * collection query reaches every one on the site. These documents are\n * editing history — the besigner's draft surface — and nothing renders from\n * them.\n */\nexport interface HostDocumentVersion {\n displayName?: string\n /** The node a placed copy grafts from; absent on a tree never published. */\n rootId?: Aglyn.NodeId\n nodes?: Record<Aglyn.NodeId, Aglyn.AglynNodeSchema>\n updatedAt?: unknown\n}\n\nexport interface HostDocumentVersionIds {\n hostId: string\n /** The subcollection under `hosts/{hostId}` the document lives in. */\n collection: string\n docId: string\n versionId: string\n}\n\nexport const useHostDocumentVersionRef = ({\n hostId,\n collection,\n docId,\n versionId,\n}: HostDocumentVersionIds) => {\n const firestore = useFirestore()\n const ref = doc(\n firestore,\n 'hosts',\n hostId,\n collection,\n docId,\n 'versions',\n versionId,\n )\n return ref.withConverter({\n toFirestore(data) {\n const { $id, ...rest } = data\n // Only emit `nodes` when the write carries them — see the note in\n // use-screen-version (AGL-1250). A partial write that compressed\n // `undefined` would replace the stored tree with an empty one.\n if (rest?.nodes === undefined)\n return { ...rest, updatedAt: Timestamp.now() }\n const nodes =\n rest.nodes instanceof Bytes\n ? rest.nodes\n : Bytes.fromUint8Array(compress(rest.nodes))\n return { ...rest, nodes, updatedAt: Timestamp.now() }\n },\n fromFirestore(snapshot, options) {\n if (!snapshot.exists()) return undefined\n const data = snapshot.data(options)\n if (data?.nodes instanceof Bytes) {\n return {\n ...data,\n nodes: decompress(data.nodes),\n } as HostDocumentVersion\n }\n return data as HostDocumentVersion\n },\n }) as DocumentReference<HostDocumentVersion>\n}\n\nexport const useHostDocumentVersion = (\n ids: HostDocumentVersionIds,\n options?: FirestoreDocOptions<HostDocumentVersion>,\n) => {\n return useDoc(useHostDocumentVersionRef(ids), options)\n}\n\nexport default useHostDocumentVersion\n"],"names":["compress","decompress","Timestamp","Bytes","doc","useFirestore","useDoc","useHostDocumentVersionRef","hostId","collection","docId","versionId","firestore","ref","withConverter","toFirestore","data","$id","rest","nodes","undefined","updatedAt","now","fromUint8Array","fromFirestore","snapshot","options","exists","useHostDocumentVersion","ids"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,4EAA4E;AAC5E,yEAAyE;AACzE,wEAAwE;;;AAExE,SAASA,QAAQ,EAAEC,UAAU,QAAQ,eAAc;AACnD,SAASC,SAAS,QAAQ,+BAA8B;AAExD,SAASC,KAAK,EAAEC,GAAG,QAAQ,qBAAoB;AAC/C,SACEC,YAAY,QAEP,kCAA8B;AACrC,OAAOC,YAAY,uBAAmB;AA+BtC,OAAO,MAAMC,4BAA4B,CAAC,EACxCC,MAAM,EACNC,UAAU,EACVC,KAAK,EACLC,SAAS,EACc;IACvB,MAAMC,YAAYP;IAClB,MAAMQ,MAAMT,IACVQ,WACA,SACAJ,QACAC,YACAC,OACA,YACAC;IAEF,OAAOE,IAAIC,aAAa,CAAC;QACvBC,aAAYC,IAAI;YACd,MAAM,EAAEC,GAAG,EAAW,GAAGD,MAATE,wCAASF;;;YACzB,kEAAkE;YAClE,iEAAiE;YACjE,+DAA+D;YAC/D,IAAIE,CAAAA,wBAAAA,KAAMC,KAAK,MAAKC,WAClB,OAAO,aAAKF;gBAAMG,WAAWnB,UAAUoB,GAAG;;YAC5C,MAAMH,QACJD,KAAKC,KAAK,YAAYhB,QAClBe,KAAKC,KAAK,GACVhB,MAAMoB,cAAc,CAACvB,SAASkB,KAAKC,KAAK;YAC9C,OAAO,aAAKD;gBAAMC;gBAAOE,WAAWnB,UAAUoB,GAAG;;QACnD;QACAE,eAAcC,QAAQ,EAAEC,OAAO;YAC7B,IAAI,CAACD,SAASE,MAAM,IAAI,OAAOP;YAC/B,MAAMJ,OAAOS,SAAST,IAAI,CAACU;YAC3B,IAAIV,CAAAA,wBAAAA,KAAMG,KAAK,aAAYhB,OAAO;gBAChC,OAAO,aACFa;oBACHG,OAAOlB,WAAWe,KAAKG,KAAK;;YAEhC;YACA,OAAOH;QACT;IACF;AACF,EAAC;AAED,OAAO,MAAMY,yBAAyB,CACpCC,KACAH;IAEA,OAAOpB,OAAOC,0BAA0BsB,MAAMH;AAChD,EAAC;AAED,eAAeE,uBAAsB"}
|
|
@@ -38,7 +38,7 @@ export declare function orgMemberOptions(members: ReadonlyArray<Partial<AglynOrg
|
|
|
38
38
|
*
|
|
39
39
|
* ## It is OFF unless a caller asks
|
|
40
40
|
*
|
|
41
|
-
* `enabled` defaults to false, the way `
|
|
41
|
+
* `enabled` defaults to false, the way `useSiteContainerOptions` does and for the
|
|
42
42
|
* same reason: the picker this feeds sits on surfaces a reader opens for
|
|
43
43
|
* something else, and a roster fetched on every arrival at the contacts list
|
|
44
44
|
* is a request for a control nobody has opened. Callers turn it on when the
|
|
@@ -44,7 +44,7 @@ import { useUser } from "./firebase/firebase-services.js";
|
|
|
44
44
|
*
|
|
45
45
|
* ## It is OFF unless a caller asks
|
|
46
46
|
*
|
|
47
|
-
* `enabled` defaults to false, the way `
|
|
47
|
+
* `enabled` defaults to false, the way `useSiteContainerOptions` does and for the
|
|
48
48
|
* same reason: the picker this feeds sits on surfaces a reader opens for
|
|
49
49
|
* something else, and a roster fetched on every arrival at the contacts list
|
|
50
50
|
* is a request for a control nobody has opened. Callers turn it on when the
|