import { applications as applicationsFetcher, installations as installationsFetcher, type SanityInstance, type StateSource, } from "@sanity/sdk"; import type { FetcherSnapshot } from "@sanity/sdk/_internal"; import { combineLatest, defer, EMPTY, map, mergeMap, type Observable, of, throwError, } from "rxjs"; import { APP_TYPE_NAME, type Application, ApplicationList, CanvasApplication, type CoreApp, CoreApplication, createCoreApplicationFromLocal, createStudioApplicationFromLocal, MediaLibraryApplication, parseCoreApplication, parseStudioApplication, type Studio, StudioApplication, type StudioWorkspace, } from "../../core"; import type { LocalApplication } from "../../core/applications/applications"; import { CONFIG_MODULE_EXPOSE, findDeployedConfigRef, type LocalAppConfig, parseInstalledApplication, } from "../../core/installations"; import type { OrganizationId } from "../../core/organizations"; import type { Project } from "../../core/projects"; import type { RemoteModule } from "../../core/remote-module"; import { isSanityClientError } from "../../core/shared/assertions"; import { canvas as canvasFetcher } from "./canvas.store"; import { userApplications as userApplicationsFetcher } from "./user-applications.store"; export interface ApplicationStoreInput { instance: SanityInstance; organizationId: OrganizationId; /** Apps served by attached dev servers; empty in production. */ localApplications: Observable; /** App configs those dev servers forward; empty in production. */ appConfigs: Observable; /** Gates and enriches studios: no reachable project, no dock entry. */ projects: Observable[]>; } /** * Everything the OS derives from the organization's installed applications. * @internal */ export interface OrganizationApplications { applications: ApplicationList>; /** * The media library's config as a module ref, or `null` when it has none. A * dev server's working copy wins over the deployed one, so a local config can * be tried against a real organization. */ mediaLibraryConfig: RemoteModule | null; } /** * Stays silent while the first fetch is in flight so `combineLatest` waits. A * pending sentinel is ruled out because `undefined` is a legitimate value here * (an organization with no canvas). * * `degrade` substitutes a value for a failure. It belongs at the entry because a * downstream `catchError` would end the subscription, and a degraded entry has * to keep listening for the revalidation that fixes it. Returning `undefined` * re-throws. */ function entry$( source: StateSource>, degrade?: (error: unknown) => TData | undefined, ): Observable { return source.observable.pipe( mergeMap((snapshot) => { if (snapshot.status === "error") { const fallback = degrade?.(snapshot.error); return fallback === undefined ? throwError(() => snapshot.error) : of(fallback); } return snapshot.status === "success" ? of(snapshot.data) : EMPTY; }), ); } /** * The application list and the media-library config, rebuilt whenever any * resource either draws on changes. Both come from here because both are * derived from the organization's installations — reading that resource twice * would be two subscriptions to one answer. * * Waits for every resource before emitting, so the machine never publishes a * partial list. Resources that only ever add entries degrade to empty on * failure. A failure anywhere else fails the stream, which the machine reports * as an unloadable list. */ export function applicationStore$({ instance, organizationId, // Renamed: the inner `map` destructures the values these carry. localApplications: localApplications$, appConfigs: appConfigs$, projects: projects$, }: ApplicationStoreInput): Observable { // Deferred so building the stream doesn't touch the fetcher cache. return defer(() => combineLatest({ applications: entry$( applicationsFetcher.getState(instance, { organizationId, include: [ "activeDeployment", "config.mfManifest", "config.studio", "interfaces", "workspaces", ], // Every application in one response, no pagination. limit: "none", }), // A user forbidden from listing applications may still reach a canvas or // a media library, so they get an empty list and a working shell. (error) => isSanityClientError(error) && error.statusCode === 403 ? { nextCursor: null, data: [] } : undefined, ).pipe( // The SDK returns the API payload verbatim, so the workbench's schemas // do the validating and branding. Unmodelled types are dropped. map(({ data }) => { const studios: Studio[] = []; const coreApps: CoreApp[] = []; for (const item of data as { type?: unknown }[]) { if (item.type === "studio") { studios.push(parseStudioApplication(item)); } else if (item.type === "coreApp") { coreApps.push(parseCoreApplication(item)); } } return { studios, coreApps }; }), ), // TEMPORARY (SDK-1909): legacy apps, additive to the authoritative list. userApplications: entry$( userApplicationsFetcher.getState(instance, { organizationId }), () => ({ studios: [], coreApps: [] }), ), canvas: entry$(canvasFetcher.getState(instance, { organizationId })), installations: entry$( installationsFetcher.getState(instance, { organizationId, // `access` is what identifies a media-library installation below; // `activeConfig` carries the ref of its deployed config. include: ["activeConfig", "access"], limit: "none", }), // The SDK's shape agrees; re-parsing is what attaches the branded ids. ).pipe(map((response) => response.data.map(parseInstalledApplication))), projects: projects$, localApps: localApplications$, appConfigs: appConfigs$, }).pipe( map( ({ applications: { studios, coreApps }, userApplications: { studios: userStudios, coreApps: userCoreApps }, canvas, installations, projects, localApps, appConfigs, }) => { const mediaLibraries = installations .filter( (installation) => installation.application.name === APP_TYPE_NAME["media-library"], ) .map( (installation) => new MediaLibraryApplication( installation.applicationId, installation.organizationId, ), ); // TEMPORARY (SDK-1909): an org is either migrated or it isn't, so the // two lists can't overlap and nothing here dedupes them. const allStudios = [...studios, ...userStudios]; const allCoreApps = [...coreApps, ...userCoreApps]; const studioApplications = allStudios .filter((studio) => { // Explicit project membership is what makes a studio navigable. if ( projects.find( (project) => project.id === studio.config.studio.projectId, ) === undefined ) { return false; } return studio.visibility !== "disabled"; }) .map((studio) => new StudioApplication(studio, projects)); const coreAppApplications = allCoreApps.map( (application) => new CoreApplication(application), ); const localApplications = localApps.map((data) => { if (data.type === "studio") { const remoteApplication = studioApplications.find( (studio) => studio.application.id === data.id, ); return new StudioApplication( createStudioApplicationFromLocal(data), projects, { isLocal: true, remoteApplication, }, ); } const remoteApplication = coreAppApplications.find( (coreApp) => coreApp.application.id === data.id, ); return new CoreApplication(createCoreApplicationFromLocal(data), { isLocal: true, remoteApplication, }); }); // A local config only counts when a dev server is serving the app it // belongs to, matched on the name its appType maps to. const servedNames = new Set( localApps .map((app) => (app.type === "coreApp" ? app.name : null)) .filter((name) => name !== null), ); const localConfig = appConfigs.find((config) => servedNames.has(APP_TYPE_NAME[config.appType]), ); return { applications: new ApplicationList([ ...(canvas ? [new CanvasApplication(canvas)] : []), ...mediaLibraries, ...studioApplications, ...coreAppApplications, ...localApplications, ]), mediaLibraryConfig: localConfig ? { entry: localConfig.remoteURL, moduleId: CONFIG_MODULE_EXPOSE, version: localConfig.version, } : findDeployedConfigRef(installations), }; }, ), ), ); }