Skip to content
Source
Sign inSign up

cloudAccountService.ts

213 lines7.0 KB

1import { reportAtomCommandResult, settlePromise } from "../state/runtime.ts";
2​
3/**
4 * The slice of a Clerk instance the account service needs. Clerk emits on
5 * every resource change (token refreshes, profile edits), so the service
6 * dedupes emissions down to account changes.
7 */
8export interface CloudAccountSource {
9 readonly addListener: (
10 listener: (resources: { readonly user?: { readonly id: string } | null | undefined }) => void,
11 ) => () => void;
13​
14interface CloudAccountSignIn {
15 readonly _tag: "SignIn";
16 readonly accountId: string;
17 /** Signed-in account being replaced; its data is removed before activation. */
18 readonly switchFromAccountId: string | null;
19 readonly isAccountTransition: boolean;
21​
22export type CloudAccountStep =
23 | { readonly _tag: "None" }
24 | {
25 readonly _tag: "SignOut";
26 /** Account whose data is removed; null removes data of any account. */
27 readonly previousAccountId: string | null;
28 readonly isAccountTransition: boolean;
29 }
30 | CloudAccountSignIn;
31​
32/**
33 * `previous` is undefined until Clerk's first emission. A first emission that
34 * is signed out still cleans up, so data left by an account that signed out
35 * while the app was closed does not linger.
36 */
37export function planCloudAccountStep(
38 previous: string | null | undefined,
39 next: string | null,
40): CloudAccountStep {
41 if (previous === next) return { _tag: "None" };
42 const isAccountTransition = previous !== undefined;
43 if (next === null) {
44 return { _tag: "SignOut", previousAccountId: previous ?? null, isAccountTransition };
45 }
46 return {
47 _tag: "SignIn",
48 accountId: next,
49 switchFromAccountId: previous ?? null,
50 isAccountTransition,
51 };
53​
54export interface CloudAccountHandlers<S extends CloudAccountSource> {
55 /** Synchronously drops every credential so nothing reads the old account during cleanup. */
56 readonly deactivate: () => void;
57 readonly cleanUp: (input: {
58 readonly source: S;
59 readonly accountId: string | null;
60 /** Whether `accountId` finished activating, so per-account registrations exist. */
61 readonly wasActive: boolean;
62 }) => Promise<void>;
63 /** Async work that must finish before the session is visible (draft restore etc.). */
64 readonly prepare?: (input: {
65 readonly source: S;
66 readonly accountId: string;
67 readonly isCurrent: () => boolean;
68 }) => Promise<void>;
69 readonly activate: (input: {
70 readonly source: S;
71 readonly accountId: string;
72 readonly isAccountTransition: boolean;
73 }) => void;
74 readonly onSignOutTransition?: () => void;
75 /** Releases credentials when the service stops observing a Clerk instance. */
76 readonly detach: () => void;
78​
79export type CloudAccountState =
80 | { readonly _tag: "Unobserved" }
81 | { readonly _tag: "SignedOut" }
82 | { readonly _tag: "Activating"; readonly accountId: string }
83 | { readonly _tag: "Active"; readonly accountId: string }
84 /**
85 * Activation rejected. The next emission for the same account retries the
86 * original step, so a transient failure recovers on Clerk's next resource
87 * change (token refreshes emit regularly) without re-requesting onboarding.
88 */
89 | {
90 readonly _tag: "Failed";
91 readonly accountId: string;
92 readonly step: CloudAccountSignIn;
93 readonly wasActive: boolean;
94 };
95​
96export interface CloudAccountService<S extends CloudAccountSource> {
97 /**
98 * Observes `source` until the returned function runs. Attaching while another
99 * source is attached detaches that one first.
100 */
101 readonly attach: (source: S) => () => void;
102 readonly getState: () => CloudAccountState;
104​
105export function createCloudAccountService<S extends CloudAccountSource>(
106 handlers: CloudAccountHandlers<S>,
107): CloudAccountService<S> {
108 let attached: { readonly source: S; readonly unsubscribe: () => void } | null = null;
109 let state: CloudAccountState = { _tag: "Unobserved" };
110 // Bumped on every account change so a superseded activation never applies.
111 let generation = 0;
112 let tail: Promise<void> = Promise.resolve();
113​
114 const enqueue = (label: string, operation: () => Promise<void>) => {
115 const run = tail.then(operation, operation);
116 tail = run.then(
117 () => undefined,
118 () => undefined,
119 );
120 void settlePromise(() => run).then((result) => reportAtomCommandResult(result, { label }));
121 };
122​
123 const observedAccount = (): string | null | undefined => {
124 switch (state._tag) {
125 case "Unobserved":
126 return undefined;
127 case "SignedOut":
128 return null;
129 default:
130 return state.accountId;
131 }
132 };
133​
134 const activate = (source: S, step: CloudAccountSignIn, wasActive: boolean) => {
135 const activation = generation;
136 const isCurrent = () => activation === generation;
137 state = { _tag: "Activating", accountId: step.accountId };
138 if (step.switchFromAccountId !== null) handlers.deactivate();
139 enqueue("cloud account activation", async () => {
140 try {
141 if (step.switchFromAccountId !== null) {
142 await handlers.cleanUp({ source, accountId: step.switchFromAccountId, wasActive });
143 }
144 if (!isCurrent()) return;
145 await handlers.prepare?.({ source, accountId: step.accountId, isCurrent });
146 } catch (error) {
147 if (isCurrent()) state = { _tag: "Failed", accountId: step.accountId, step, wasActive };
148 throw error;
149 }
150 if (!isCurrent()) return;
151 state = { _tag: "Active", accountId: step.accountId };
152 handlers.activate({
153 source,
154 accountId: step.accountId,
155 isAccountTransition: step.isAccountTransition,
156 });
157 });
158 };
159​
160 const observe = (source: S, next: string | null) => {
161 const previousState = state;
162 if (previousState._tag === "Failed" && previousState.accountId === next) {
163 generation += 1;
164 activate(source, previousState.step, previousState.wasActive);
165 return;
166 }
167 const step = planCloudAccountStep(observedAccount(), next);
168 if (step._tag === "None") return;
169 generation += 1;
170 const wasActive = previousState._tag === "Active";
171​
172 if (step._tag === "SignOut") {
173 state = { _tag: "SignedOut" };
174 if (step.isAccountTransition) handlers.onSignOutTransition?.();
175 handlers.deactivate();
176 enqueue("cloud account cleanup", () =>
177 handlers.cleanUp({ source, accountId: step.previousAccountId, wasActive }),
178 );
179 return;
180 }
181​
182 activate(source, step, wasActive);
183 };
184​
185 const detach = () => {
186 if (!attached) return;
187 attached.unsubscribe();
188 attached = null;
189 generation += 1;
190 state = { _tag: "Unobserved" };
191 handlers.detach();
192 };
193​
194 return {
195 attach(source) {
196 detach();
197 const current = {
198 source,
199 unsubscribe: source.addListener((resources) => {
200 // Clerk reports `user: undefined` until it loads; pending sessions
201 // still carry a user and count as signed in.
202 if (resources.user === undefined) return;
203 observe(source, resources.user?.id ?? null);
204 }),
205 };
206 attached = current;
207 return () => {
208 if (attached === current) detach();
209 };
210 },
211 getState: () => state,
212 };