import { deepClone, delay, uniqueId } from "@milaboratories/helpers"; import type { Mutable } from "@milaboratories/helpers"; import type { NavigationState, BlockOutputsBase, BlockState, PlatformaV2, ValueWithUTag, AuthorMarker, PlatformaExtended, } from "@platforma-sdk/model"; import { hasAbortError, unwrapResult } from "@platforma-sdk/model"; import type { Ref } from "vue"; import { reactive, computed, ref } from "vue"; import type { StateModelOptions, UnwrapOutputs, OutputValues, OutputErrors, AppSettings, } from "../types"; import { createModel } from "../createModel"; import { parseQuery } from "../urls"; import { ensureOutputHasStableFlag, MultiError, unwrapOutput } from "../utils"; import { applyPatch } from "fast-json-patch"; import { UpdateSerializer } from "./UpdateSerializer"; import { watchIgnorable } from "@vueuse/core"; export const patchPoolingDelay = 150; export const createNextAuthorMarker = (marker: AuthorMarker | undefined): AuthorMarker => ({ authorId: marker?.authorId ?? uniqueId(), localVersion: (marker?.localVersion ?? 0) + 1, }); const stringifyForDebug = (v: unknown) => { try { return JSON.stringify(v, null, 2); } catch (err) { return err instanceof Error ? err.message : String(err); } }; /** * Creates an application instance with reactive state management, outputs, and methods for state updates and navigation. * * @template Args - The type of arguments used in the application. * @template Outputs - The type of block outputs extending `BlockOutputsBase`. * @template UiState - The type of the UI state. * @template Href - The type of navigation href, defaulting to a string starting with `/`. * * @param state - Initial state of the application, including args, outputs, UI state, and navigation state. * @param platforma - A platform interface for interacting with block states. * @param settings - Application settings, such as debug flags. * * @returns A reactive application object with methods, getters, and state. */ export function createAppV2< Args = unknown, Outputs extends BlockOutputsBase = BlockOutputsBase, UiState = unknown, Href extends `/${string}` = `/${string}`, >( state: ValueWithUTag>, platforma: PlatformaExtended>, settings: AppSettings, ) { const debug = (msg: string, ...rest: unknown[]) => { if (settings.debug) { console.log( `%c>>> %c${msg}`, "color: orange; font-weight: bold", "color: orange", ...rest.map((r) => stringifyForDebug(r)), ); } }; const error = (msg: string, ...rest: unknown[]) => { console.error( `%c>>> %c${msg}`, "color: red; font-weight: bold", "color: red", ...rest.map((r) => stringifyForDebug(r)), ); }; const data = { isExternalSnapshot: false, author: { authorId: uniqueId(), localVersion: 0, }, }; const nextAuthorMarker = () => { data.author = createNextAuthorMarker(data.author); debug("nextAuthorMarker", data.author); return data.author; }; const closedRef = ref(false); const uTagRef = ref(state.uTag); const debounceSpan = settings.debounceSpan ?? 200; const setArgsQueue = new UpdateSerializer({ debounceSpan }); const setUiStateQueue = new UpdateSerializer({ debounceSpan }); const setArgsAndUiStateQueue = new UpdateSerializer({ debounceSpan }); const setNavigationStateQueue = new UpdateSerializer({ debounceSpan }); /** * Reactive snapshot of the application state, including args, outputs, UI state, and navigation state. */ const snapshot = ref<{ args: Args; outputs: Partial; ui: UiState; navigationState: NavigationState; }>(state.value) as Ref<{ args: Args; outputs: Partial; ui: UiState; navigationState: NavigationState; }>; const setBlockArgs = async (args: Args) => { return platforma.setBlockArgs(args, nextAuthorMarker()); }; const setBlockUiState = async (ui: UiState) => { return platforma.setBlockUiState(ui, nextAuthorMarker()); }; const setBlockArgsAndUiState = async (args: Args, ui: UiState) => { return platforma.setBlockArgsAndUiState(args, ui, nextAuthorMarker()); }; const setNavigationState = async (state: NavigationState) => { return platforma.setNavigationState(state); }; const outputs = computed>(() => { const entries = Object.entries(snapshot.value.outputs as Partial>).map( ([k, outputWithStatus]) => platforma.blockModelInfo.outputs[k].withStatus ? [k, ensureOutputHasStableFlag(outputWithStatus)] : [ k, outputWithStatus.ok && outputWithStatus.value !== undefined ? outputWithStatus.value : undefined, ], ); return Object.fromEntries(entries); }); const outputErrors = computed>(() => { const entries = Object.entries(snapshot.value.outputs as Partial>).map( ([k, vOrErr]) => [ k, vOrErr && vOrErr.ok === false ? new MultiError(vOrErr.errors) : undefined, ], ); return Object.fromEntries(entries); }); const appModel = reactive({ error: "", model: { args: deepClone(snapshot.value.args) as Args, ui: deepClone(snapshot.value.ui) as UiState, outputs, outputErrors, }, }) as { error: string; model: { args: Args; ui: UiState; outputs: OutputValues; outputErrors: OutputErrors; }; }; const { ignoreUpdates } = watchIgnorable( () => appModel.model, (_newData) => { const newData = deepClone(_newData); debug("setArgsAndUiStateQueue appModel.model, args", newData.args, "ui", newData.ui); setArgsAndUiStateQueue.run(() => setBlockArgsAndUiState(newData.args, newData.ui).then(unwrapResult), ); }, { deep: true }, ); const updateAppModel = (newData: { args: Args; ui: UiState }) => { debug("updateAppModel", newData); appModel.model.args = deepClone(newData.args) as Args; appModel.model.ui = deepClone(newData.ui) as UiState; }; (async () => { window.addEventListener("beforeunload", () => { closedRef.value = true; platforma .dispose() .then(unwrapResult) .catch((err) => { error("error in dispose", err); }); }); while (!closedRef.value) { try { const patches = await platforma.getPatches(uTagRef.value).then(unwrapResult); debug("patches.length", patches.value.length); debug("uTagRef.value", uTagRef.value); debug("patches.uTag", patches.uTag); debug("patches.author", patches.author); debug("data.author", data.author); uTagRef.value = patches.uTag; if (patches.value.length === 0) { await new Promise((resolve) => setTimeout(resolve, patchPoolingDelay)); continue; } const isAuthorChanged = data.author?.authorId !== patches.author?.authorId; // Immutable behavior, apply external changes to the snapshot if (isAuthorChanged || data.isExternalSnapshot) { debug("got external changes, applying them to the snapshot", patches.value); ignoreUpdates(() => { snapshot.value = applyPatch(snapshot.value, patches.value, false, false).newDocument; updateAppModel({ args: snapshot.value.args, ui: snapshot.value.ui }); data.isExternalSnapshot = isAuthorChanged; }); } else { // Mutable behavior debug("outputs changed", patches.value); ignoreUpdates(() => { snapshot.value = applyPatch(snapshot.value, patches.value).newDocument; }); } await new Promise((resolve) => setTimeout(resolve, patchPoolingDelay)); } catch (err) { if (hasAbortError(err)) { debug("patches loop aborted"); closedRef.value = true; } else { error("error in patches loop", err); await new Promise((resolve) => setTimeout(resolve, 1000)); } } } })(); const cloneArgs = () => deepClone(appModel.model.args) as Args; const cloneUiState = () => deepClone(appModel.model.ui) as UiState; const cloneNavigationState = () => deepClone(snapshot.value.navigationState) as Mutable>; const methods = { cloneArgs, cloneUiState, cloneNavigationState, createArgsModel(options: StateModelOptions = {}) { return createModel({ get() { if (options.transform) { return options.transform(snapshot.value.args as Args); } return snapshot.value.args as T; }, validate: options.validate, autoSave: true, onSave(newArgs) { setArgsQueue.run(() => setBlockArgs(newArgs).then(unwrapResult)); }, }); }, /** * defaultUiState is temporarily here, remove it after implementing initialUiState */ createUiModel( options: StateModelOptions = {}, defaultUiState: () => UiState, ) { return createModel({ get() { if (options.transform) { return options.transform(snapshot.value.ui as UiState); } return (snapshot.value.ui ?? defaultUiState()) as T; }, validate: options.validate, autoSave: true, onSave(newData) { setUiStateQueue.run(() => setBlockUiState(newData).then(unwrapResult)); }, }); }, /** * Retrieves the unwrapped values of outputs for the given keys. * * @template K - Keys of the outputs to unwrap. * @param keys - List of output names. * @throws Error if the outputs contain errors. * @returns An object with unwrapped output values. */ unwrapOutputs(...keys: K[]): UnwrapOutputs { const outputs = snapshot.value.outputs as Partial>; const entries = keys.map((key) => [key, unwrapOutput(outputs[key])]); return Object.fromEntries(entries); }, /** * Updates the arguments state by applying a callback. * * @param cb - Callback to modify the current arguments. * @returns A promise resolving after the update is applied. */ updateArgs(cb: (args: Args) => void): Promise { const newArgs = cloneArgs(); cb(newArgs); debug("updateArgs", newArgs); appModel.model.args = newArgs; return setArgsQueue.run(() => setBlockArgs(newArgs).then(unwrapResult)); }, /** * Updates the UI state by applying a callback. * * @param cb - Callback to modify the current UI state. * @returns A promise resolving after the update is applied. * @todo Make it mutable since there is already an initial one */ updateUiState(cb: (args: UiState) => UiState): Promise { const newUiState = cb(cloneUiState()); debug("updateUiState", newUiState); appModel.model.ui = newUiState; return setUiStateQueue.run(() => setBlockUiState(newUiState).then(unwrapResult)); }, /** * Navigates to a specific href by updating the navigation state. * * @param href - The target href to navigate to. * @returns A promise resolving after the navigation state is updated. */ navigateTo(href: Href) { const newState = cloneNavigationState(); newState.href = href; return setNavigationStateQueue.run(() => setNavigationState(newState).then(unwrapResult)); }, async allSettled() { await delay(0); return setArgsAndUiStateQueue.allSettled(); }, }; const getters = { closedRef, snapshot, queryParams: computed(() => parseQuery(snapshot.value.navigationState.href as Href)), href: computed(() => snapshot.value.navigationState.href), hasErrors: computed(() => Object.values(snapshot.value.outputs as Partial>).some((v) => !v?.ok), ), }; const app = reactive(Object.assign(appModel, methods, getters)); if (settings.debug) { // @ts-expect-error (to inspect in console in debug mode) globalThis.__block_app__ = app; } return app; } export type BaseAppV2< Args = unknown, Outputs extends BlockOutputsBase = BlockOutputsBase, UiState = unknown, Href extends `/${string}` = `/${string}`, > = ReturnType>;