import { projects, type SanityInstance } from "@sanity/sdk"; import { distinctUntilChanged, filter, map } from "rxjs"; import { assign, fromObservable, setup } from "xstate"; import type { OrganizationId } from "../core/organizations"; import { parseProject, type Project } from "../core/projects"; import type { OSBaseInput } from "./root.machine"; type Projects = Project[]; /** @internal */ export interface ProjectsInput extends OSBaseInput { organizationId: OrganizationId; } // Stays subscribed for the length of the session. The store keeps emitting after // the first value (revalidations, invalidations, newly granted access), and a // one-shot read would strand every consumer on the boot-time list. const projectsStateLogic = fromObservable( ({ input }) => projects .getState(input.instance, { organizationId: input.organizationId, // The workbench only asks "may this user reach this project", so it reads // the narrowest shape that answers it: `onlyExplicitMembership` limits the // list to projects the user holds a role on, which is what makes a studio // navigable, and `includeMembers` would add a table join for data nothing // here reads. includeMembers: false, includeFeatures: false, onlyExplicitMembership: true, }) .observable.pipe( // Fetcher snapshots carry their status, so a failed fetch has to be // rethrown here for `onError` to fail the actor. map((snapshot) => { if (snapshot.status === "error") throw snapshot.error; if (snapshot.status !== "success") return null; // The SDK's shape agrees field for field; re-parsing is what attaches // the branded ids the studio classes want. return snapshot.data.map((project) => parseProject(project, { includeMembers: false, includeFeatures: false, // onlyExplicitMembership filters the SDK query; it does not alter // the project response shape the parser validates. }), ); }), filter((list): list is Projects => list !== null), // Consumers rebuild off this list, so a revalidation that returned the // same projects has to stay quiet. distinctUntilChanged( (a, b) => a.length === b.length && a.every((project, i) => project.id === b[i]?.id), ), ), ); type ProjectsContext = { instance: SanityInstance; organizationId: OrganizationId; /** The organization's projects; `undefined` until the first fetch settles. */ projects: Projects | undefined; }; /** * Owns the organization's projects for the length of the session. They gate and * enrich other resources: a studio needs a reachable project to reach the * application list, and a studio's workspaces resolve their project from here. */ export const projectsLogic = setup({ types: { input: {} as ProjectsInput, context: {} as ProjectsContext, tags: {} as "projects-resolved" | "error", }, actors: { projectsState: projectsStateLogic }, actions: { storeProjects: assign({ projects: (_, params: { projects: Projects | undefined }) => params.projects, }), }, }).createMachine({ id: "projects", context: ({ input }) => ({ instance: input.instance, organizationId: input.organizationId, projects: undefined, }), invoke: { src: "projectsState", input: ({ context }) => ({ instance: context.instance, organizationId: context.organizationId, }), onSnapshot: { guard: ({ event }) => Boolean(event.snapshot.context), target: ".resolved", actions: [ { type: "storeProjects", params: ({ event }) => ({ projects: event.snapshot.context }), }, ], }, onError: { target: ".failed" }, }, initial: "resolving", states: { resolving: {}, resolved: { tags: ["projects-resolved"] }, failed: { tags: ["error"] }, }, });