import { number } from "superstruct"; import { and, assertEvent, assign, fromCallback, fromPromise, setup, } from "xstate"; import { createSafeStorage, type SafeStorageKey, } from "../createSafeStorage.ts"; import { currentUser, sessionInfo } from "../structs.ts"; import type { CurrentUser, SessionInfo } from "../types.ts"; import type { MachineContext, MachineEvent, PullResult, PushResult, SyncedItem, } from "./types.ts"; import { assertNonNullish, caughtToResult } from "./utils.ts"; const safeStorage = createSafeStorage({ currentUser: currentUser(), sessionInfo: sessionInfo(), lastSuccessfulSyncTs: number(), }); function createInMemoryContext(): Omit< MachineContext, SafeStorageKey > { return { currentSync: null, pushOnceIdle: false, syncLog: [], }; } function createInitialContext(): MachineContext { return { ...createInMemoryContext(), currentUser: safeStorage.getItem("currentUser"), sessionInfo: safeStorage.getItem("sessionInfo"), lastSuccessfulSyncTs: safeStorage.getItem("lastSuccessfulSyncTs"), }; } const sync = fromCallback(() => { throw new Error(`Sync actor not provided.`); }); const auth = fromCallback(() => { throw new Error(`Auth actor not provided.`); }); export type PullChangesInput = { sinceTs: number | null; currentUser: CurrentUser; }; const pullChanges = fromPromise(() => { throw new Error(`Pull changes actor not provided.`); }); export type PushChangesInput = { sinceTs: number | null; currentSyncTs: number; ignoredItems: SyncedItem[]; currentUser: CurrentUser; }; const pushChanges = fromPromise(async () => { throw new Error(`Push changes actor not provided.`); }); const config = setup({ types: { context: {} as MachineContext, events: {} as MachineEvent, children: {} as { sync: "sync"; auth: "auth"; }, }, actors: { auth, sync, pullChanges, pushChanges, }, actions: { setCurrentSync: assign({ currentSync: () => ({ startedTs: Date.now(), pull: null, push: null }), }), flushCurrentSync: assign(({ context }) => { const { currentSync, syncLog } = context; assertNonNullish( currentSync, "Flushing current sync but context.currentSync is nullish.", ); return { currentSync: null, syncLog: [currentSync, ...syncLog], }; }), setLastSuccessfulSync: assign(({ context }) => { assertNonNullish( context.currentSync, "Setting last successful sync but context.currentSync is nullish.", ); const lastSuccessfulSyncTs = context.currentSync.startedTs; safeStorage.setItem("lastSuccessfulSyncTs", lastSuccessfulSyncTs); return { lastSuccessfulSyncTs }; }), markSyncStepResult: assign({ currentSync: ( { context }, params: { pull?: PullResult; push?: PushResult }, ) => { assertNonNullish( context.currentSync, "Marking sync step result but context.currentSync is nullish.", ); return { ...context.currentSync, ...params, }; }, }), queuePush: assign({ pushOnceIdle: true }), clearPushQueue: assign({ pushOnceIdle: false }), setCurrentUser: assign((_, currentUser: CurrentUser) => { safeStorage.setItem("currentUser", currentUser); return { currentUser }; }), patchSessionInfo: assign(({ context }, newSessionInfo: SessionInfo) => { const sessionInfo = context.sessionInfo ? { ...context.sessionInfo, expiresTs: newSessionInfo.expiresTs } : newSessionInfo; safeStorage.setItem("sessionInfo", sessionInfo); return { sessionInfo }; }), resetContext: assign((): MachineContext => { safeStorage.clear(); return { ...createInMemoryContext(), currentUser: null, sessionInfo: null, lastSuccessfulSyncTs: null, }; }), resetWithServerUser: assign((_, serverUser: CurrentUser) => { safeStorage.clear(); safeStorage.setItem("currentUser", serverUser); return { ...createInMemoryContext(), currentUser: serverUser, sessionInfo: null, lastSuccessfulSyncTs: null, }; }), }, guards: { shouldPushOnceIdle: ({ context }) => { return context.pushOnceIdle; }, isEligibleForSync: and(["isUserVerified", "isNotMismatched"]), isUserVerified: ({ event }) => { assertEvent(event, "currentUser"); return event.currentUser.isVerified; }, isNotMismatched: ({ event, context }) => { assertEvent(event, "currentUser"); // If there is no current user, there cannot be a mismatch if (!context.currentUser) { return true; } // Otherwise check that user IDs match. return context.currentUser.id === event.currentUser.id; }, hasBecomeEligibleForSync: and(["isNotMismatched", "hasBecomeVerified"]), hasBecomeVerified: ({ event, context }) => { assertEvent(event, "currentUser"); if (!context.currentUser) { return false; } // User was unverified previously, but now they are verified. return ( context.currentUser.isVerified === false && event.currentUser.isVerified === true ); }, }, }); export type AppMachine = typeof machine; export const machine = config.createMachine({ /** @xstate-layout N4IgpgJg5mDOIC5QEMAOqDEAnOYAuA2gAwC6ioqA9rAJZ42UB25IAHogLQBMRAbAHQBOQbwCMggOy8ALES7DRAGhABPTvIn8AzAA5B0rdN4BWCRKKDRvAL7XlaVPwCujZE7wALMI3oBjZHiQGL5OWDg+AKqwYFjEZEggVLT0TCzsCEZc-FyiXHqiOtJcvLwSymoI3Fqi-LkiIjkSIrIStvbozq7uXj40-oEQwaHheFExBKLxFNR0DMwJ6dJL-GJ1gkTSwubS5Yj6mjrG602F0ietdiAOnW6e3n4BQSFh92OxXFOJMynzoIs6uwQljaVw6LluPQeAww0SwADcYm84iwkrNUgtEHkiICOKI8SDrhD7n1HoNniMkaQUd85mlELkctktLxDkQdDoJFYuGVVPTREZaoZjMKdNzRFILu1HETev0nsNXrCJp9UT86QhRVkiHVjDIiHwSlxAXiBaJDEVqkRzNVpASOjKofKXpElR9qclaRiENjed67dLusS5YNorBaEwAJKMABmlGRCVVnr+9JKAkE3LyPF4zN0Sl9JoEZukwuMos5Ev9-AdJOhofDjAAoqxUDQcBB49MPejkwhDDUtERqjwDBIuMYszitOnsmaOdJcqW5zZLoTA7LSfxYCpGL5N9vfDRGFAMBAmGBN3hHlX7WvHRA9zuHwejx2vl3fmxMcVpPxjOLNgOeISAYgLcsY-DmJIxjVJYg6iBslbVsGT5Poex6oE4sAeK+ibdp+CDwdqQjSHogimPopiGICxaaAU6bchseS6IIiG3jWkAoVuO5ofwGEADZ8WhJ5nvwh5wpQADW56rnc64DJx+48fxglHggYmUP0cxxDhNJ4ekuQbPwhz6AYA6FCUWjGgIHLCLopj8tqJmsbJd4KdxR68U4AlCTEWCUFgvF8QEsZYAAtteAYuex95cbusVKV5KlQGpjDiZpTDaVSCa6R++n8j+YjmKYo7ATovDGgY2SbCY2YkUczmQtFbnPlAnlYUJp6MOe6lSRFVZschsWoR5GHtap6npYwmUqjl6pWBIOhGdq1r6pyYqAgOtR8MY2q6HkYiag1QYbkN8UjZhHg+WE-mBcF-nhTJjWDfuw2taNl3jalGkBFppA6e+c26tZpksiRFjzgCvpcFwP7QUU46CDoQH8sYR1yRxQ00BAfFgBg73-WiuX0usAh-sBghaOYZWFICUg-nR0HajDejAWjrmY9juPKQTapejkxRLbk7KDoawqAtmgi-tDxhLNBZljmzTUczjGA80m+EFGI2haDrZhyKKxZGr6SNaNrOvjkU2aiqIivPY+ADuyCzEeeMXWren0lIWRkwBFhIzyFQslkdE8DLXBTtmkqgpFT0nS9jvO8erCwJegRVtGgRYAAFKIO1EAAlBgj3HfJQ0J-QL5ZZ2hNzUUWTcrwMMSNBZEmYCpaaI3lN8PqPDAbaIKMJQEBwCwDjujXXrcDLQgiOIdNyAoOKrLUBjjloPDHFOlbggNpIT7zPa4sBEH6gObImBy+qQxUHBGDUueitmMhmufqMrjeUXBgf6vpAbTI6CIKYAC9RLK+jvlIFYa9zakUcu-KU-Uv4bkPGAQSUAaAACMcY-w9kCQy4ckZ4gUGRcQgIKbgRRpsM0RYmhR2LujGK+4cFEw1KOABQDyYRwppOZYMgtC6m1DIUUSNlwIKQnHR8Z0oDMPVOmIg7DgEU1ATicUi0+FwxkLBeCdDP6x1Li9KRnlvJHhkV6EQi0CEcJAbwbh+YCrbW1MtWQiMWIfxjiXDGBjFLnTGtI7KAMvT8jyPwIw1ReBskKFYaCFUjI6z4Pwng1DCq2wkXFbxrVYqQFMT2cQeJT4imECRIoAd6Sm34SYfUiMzDh3HDoFJ+jJHpP4NGJ2OMIDZPwokzQ9l5xHHgrnBaoEpy1DMCRGGMMRBmFEdHRBejPGPixtg-xk8ew5AKNoJmHIdDdw5LTRu2hdTimbsAsU9T5m7nLmhDp+kIbaE5Drfko4ii5D2VkcpRzhTAW5FoWwtggA */ id: "app", context: () => createInitialContext(), invoke: { id: "auth", src: "auth", }, initial: "unauthenticated", on: { reset: [ { actions: "resetContext", target: ".unauthenticated", }, ], }, states: { unauthenticated: { description: "The user is either completely anonymous, authenticated via insecure means (localStorage), or in an otherwise no-correctly-authenticated state (e.g. user mismatch).", on: { currentUser: [ { guard: "isEligibleForSync", target: "authenticated", actions: { type: "setCurrentUser", params: ({ event }) => event.currentUser, }, }, { guard: "isNotMismatched", target: "authenticated.ineligible", actions: { type: "setCurrentUser", params: ({ event }) => event.currentUser, }, }, { target: "unauthenticated" }, ], serverUser: { target: "authenticated", actions: { type: "resetWithServerUser", params: ({ event }) => event.serverUser, }, }, }, }, authenticated: { description: "The user has a session with ITC.", initial: "sync", on: { currentUser: [ { guard: "hasBecomeEligibleForSync", target: "authenticated", reenter: true, actions: { type: "setCurrentUser", params: ({ event }) => event.currentUser, }, }, { guard: "isNotMismatched", actions: { type: "setCurrentUser", params: ({ event }) => event.currentUser, }, }, { target: "unauthenticated", }, ], sessionInfo: { actions: { type: "patchSessionInfo", params: ({ event }) => event.sessionInfo, }, }, sessionExpired: { target: "unauthenticated", }, }, states: { ineligible: { description: "The user is not eligible for sync (e.g. unverified)", }, sync: { description: "The user is eligible for the sync feature.", invoke: { id: "sync", src: "sync", }, initial: "syncing", states: { syncing: { description: "The sync operation is in progress.", initial: "pulling", entry: "setCurrentSync", exit: "flushCurrentSync", on: { push: { actions: "queuePush", }, }, states: { pulling: { invoke: { src: "pullChanges", input: ({ context }) => ({ sinceTs: context.lastSuccessfulSyncTs, currentUser: context.currentUser!, }), onDone: { target: "pushing", actions: [ { type: "markSyncStepResult", params: ({ event }) => ({ pull: event.output, }), }, ], }, onError: { target: "failed", actions: { type: "markSyncStepResult", params: ({ event }) => ({ pull: caughtToResult(event.error), }), }, }, }, }, pushing: { entry: "clearPushQueue", invoke: { src: "pushChanges", input: ({ context }) => { assertNonNullish( context.currentSync, "Setting pushChanges input but context.currentSync is unset.", ); assertNonNullish( context.currentUser, "Setting pushChanges input but context.currentUser is unset.", ); // This value might be unset if no push was made during // this sync attempt. const pulledItems = context.currentSync.pull?.valueOrNull()?.pulled ?? []; return { ignoredItems: pulledItems, currentSyncTs: context.currentSync.startedTs, sinceTs: context.lastSuccessfulSyncTs, currentUser: context.currentUser, }; }, onDone: { target: "synced", actions: { type: "markSyncStepResult", params: ({ event }) => ({ push: event.output, }), }, }, onError: { target: "failed", actions: { type: "markSyncStepResult", params: ({ event }) => ({ push: caughtToResult(event.error), }), }, }, }, }, synced: { type: "final", entry: "setLastSuccessfulSync", }, failed: { type: "final", }, }, onDone: "idle", }, idle: { description: "The app is listening for sync events.", always: { guard: "shouldPushOnceIdle", target: "waiting", }, on: { push: "waiting", pull: "syncing", }, }, waiting: { description: "We are waiting for further push events to batch them.", after: { 1500: { target: "syncing.pushing", }, }, on: { push: { target: "waiting", reenter: true, }, }, }, }, }, }, }, }, });