import { DocUpdatesForCloud, ReactiveUtilities, SavedJson } from "./Persisters"; import { DeepReadonlyJson, Json, JsonObject, primitiveFormatTag, } from "./Primitives"; import { v4 as uuidv4 } from "uuid"; // export type IS_JUST_IN_MEMORY_TABLE = typeof IS_JUST_IN_MEMORY_TABLE; // export const IS_JUST_IN_MEMORY_TABLE = Symbol.for("IsJustInMemoryTable"); /** TODO: Set up an instance dictionary so direct comparison can happen. */ export function setUpTableFactory< CanCacheOffline extends boolean, Checkpoint extends Json, >(factoryConfig: { startCloudSynchronizer?: StartCloudSynchronizer; reactiveUtils: ReactiveUtilities; savedJson: SavedJson; }) { const { reactiveUtils } = factoryConfig; return { table( tableConfig: { /* TODO: Make this be a reactive Set of paths. */ dbPath: string; // onDelete?: () => Promise; } & (false extends CanCacheOffline ? // Only allow offline caching if we can make savedJson(s) { /** You must provide storage config when initializing mufasa to enable offline caching. */ shouldCacheOffline?: false; } : { /* TODO: Add a way to mor granularly control what we keep an offline copy of. * cacheOfflineIf?: () => void. */ shouldCacheOffline?: boolean; }), ): { schema(getSchema: T): T extends (getSelf: any) => void ? { readonly _mfsFormat: tableFormatTag; readonly allDocs: InstFor>[]; readonly haveLoadedAllDocs: boolean; create( params: CreateParamsFor>, ): InstFor>; fromId(id: string): InstFor>; readonly TsType: InstFor>; readonly _mfs_isRequiredOnCreate: true; readonly schema: ReturnType; } : { error: "Expected a function like schema(() => ({...}))" }; } { return { schema(getSchema: (getSelf: () => any) => { [key: string]: any }) { // const deletedTag = `deleted` as const; // TODO: Eventually debouncing should be moved out of the Firebase Plugin and into here. let requestCloudPush = () => {}; let disposeCloudSync = () => {}; const { rawDocs, loadPromise, updateSavedDocs } = (() => { const initData = { docs: {} as { [id: string]: JsonObject; // | typeof deletedTag; }, cloudSyncing: { checkpoint: null as Json | null, queuedChanges: {} as DocUpdatesForCloud, inProgressChanges: {} as DocUpdatesForCloud, }, }; const store = tableConfig.shouldCacheOffline ? factoryConfig.savedJson(tableConfig.dbPath, initData) : reactiveUtils.createStore(initData); return { rawDocs: store, updateSavedDocs(args: { updates: DeepReadonlyJson; shouldPushToCloud: boolean; }) { store.update((data) => { Object.assign(data.docs, args.updates); if (args.shouldPushToCloud) { Object.assign( data.cloudSyncing.queuedChanges, args.updates, ); console.log(`About to request a push`); requestCloudPush(); } }); }, loadPromise: (store as any as Partial>)?.loadPromise ?? Promise.resolve(), } as const; })(); // Start Cloud Sync loadPromise.then(() => { const result = factoryConfig?.startCloudSynchronizer?.({ path: tableConfig.dbPath, initialCheckpoint: rawDocs.data!.cloudSyncing.checkpoint as any, setCheckpoint: (newCheckpoint) => { rawDocs.update((data) => { data.cloudSyncing.checkpoint = newCheckpoint; }); }, getChanges: (onPushComplete) => { // Merge all queued changes into inProgress rawDocs.update((data) => { Object.assign( data.cloudSyncing.inProgressChanges, data.cloudSyncing.queuedChanges, ); data.cloudSyncing.queuedChanges = {}; }); // After a successful push, clear the inProgress changes onPushComplete.then(() => { rawDocs.update((data) => { data.cloudSyncing.inProgressChanges = {}; }); }); // Give cloud sync all changes it needs to push. return rawDocs.data!.cloudSyncing.inProgressChanges; }, updateLocalDocs: (updates) => { updateSavedDocs({ updates, shouldPushToCloud: false }); }, }); if (result) { requestCloudPush = result.requestPush; disposeCloudSync = result.dispose; } }); const getFieldSchemas = (schema: { [key: string]: any }) => { const schemaDescriptors = Object.getOwnPropertyDescriptors(schema); return Object.keys(schemaDescriptors) .filter((key) => { const desc = schemaDescriptors[key]; return ( desc.get === undefined && desc.set === undefined && typeof (schema as any)[key] !== `function` ); }) .map((key) => [key, schema[key]]); }; const getPrimSchemas = (schema: { [key: string]: any }) => { return getFieldSchemas(schema).filter( ([_, fieldSchema]) => fieldSchema._mfsFormat === primitiveFormatTag, ); }; const getTableSchemas = (schema: { [key: string]: any }) => { return getFieldSchemas(schema).filter( ([_, fieldSchema]) => fieldSchema._mfsFormat === tableFormatTag, ); }; const getGetterKeys = (schema: { [key: string]: any }) => { const schemaDescriptors = Object.getOwnPropertyDescriptors(schema); return Object.keys(schemaDescriptors).filter((key) => { const desc = schemaDescriptors[key]; return desc.get !== undefined; }); }; const getSetterKeys = (schema: { [key: string]: any }) => { const schemaDescriptors = Object.getOwnPropertyDescriptors(schema); return Object.keys(schemaDescriptors).filter((key) => { const desc = schemaDescriptors[key]; return desc.set !== undefined; }); }; const getFunctionKeys = (schema: { [key: string]: any }) => { const schemaDescriptors = Object.getOwnPropertyDescriptors(schema); return Object.keys(schema).filter((key) => { const desc = schemaDescriptors[key]; return ( desc.get === undefined && typeof schema[key] === `function` ); }); }; const createInst = (id: string) => { // Create the boilerplate instance let newInst: { [key: string]: any } = { get id() { return id; }, get isArchived() { if (rawDocs.data === null) { throw new Error(`Table not loaded yet`); } return rawDocs.data.docs[id][isArchivedKey]; }, archive() { if (rawDocs.data === null) { throw new Error(`Table not loaded yet`); } updateSavedDocs({ updates: { [id]: { [isArchivedKey]: true, }, }, shouldPushToCloud: true, }); }, }; const schema = getSchema(() => newInst); // Setup Primitives for (const [key, _] of getPrimSchemas(schema)) { Object.defineProperty(newInst, key, { get: function () { if (rawDocs.data === null) { throw new Error(`Table not loaded yet`); } return rawDocs.data.docs[id][key]; }, set: function (value) { if (rawDocs.data === null) { throw new Error(`Table not loaded yet`); } updateSavedDocs({ updates: { [id]: { [key]: value, }, }, shouldPushToCloud: true, }); }, }); } // Set Up Table Fields for (const [key, TableType] of getTableSchemas(schema)) { Object.defineProperty(newInst, key, { get: function () { if (rawDocs.data === null) { throw new Error(`Table not loaded yet`); } if (typeof rawDocs.data.docs[id][key] !== `string`) { throw new Error( `Expected a string id in the raw doc for the ${key} field`, ); } return TableType.fromId(rawDocs.data.docs[id][key]); }, set: function (value) { if (rawDocs.data === null) { throw new Error(`Table not loaded yet`); } updateSavedDocs({ updates: { [id]: { [key]: value.id, }, }, shouldPushToCloud: true, }); }, }); } // Set Up Getters for (const key of getGetterKeys(schema)) { const formula = reactiveUtils.rootFormula(() => schema[key]); Object.defineProperty(newInst, key, { get: function () { return formula.value; }, }); } // Set Up Setters for (const key of getSetterKeys(schema)) { Object.defineProperty(newInst, key, { set: function (value) { schema[key] = value; }, }); } // Set Up Functions for (const key of getFunctionKeys(schema)) { newInst[key] = schema[key]; } return newInst; }; const allDocs = reactiveUtils.rootFormula(() => rawDocs.data === null ? [] : Object.entries(rawDocs.data.docs) .filter(([_, data]) => data[isArchivedKey] !== true) .map(([id, _]) => createInst(id)), ); return { _mfsFormat: tableFormatTag, // Standard Static Features // TODO: Memoize all static and instance getters get haveLoadedAllDocs() { return rawDocs.data !== null; }, get allDocs() { return allDocs.value; }, create(createParams: { [key: string]: any }) { if (rawDocs.data === null) { throw new Error(`Table not loaded yet`); } // Create a new raw doc const newId = uuidv4(); const rawDoc: DocUpdatesForCloud[`string`] = { [isArchivedKey]: false, }; const schema = getSchema(() => {}); for (const [key, fieldSchema] of getFieldSchemas(schema)) { if (fieldSchema._mfsFormat === primitiveFormatTag) { rawDoc[key] = createParams[key] ?? fieldSchema.defaultVal; } else if (fieldSchema._mfsFormat === tableFormatTag) { rawDoc[key] = createParams[key].id; } } updateSavedDocs({ updates: { [newId]: rawDoc, }, shouldPushToCloud: true, }); // Return a new instance return createInst(newId); // Then add getters, setters, and functions // for (const key of Object.keys(getSchema())) { // newInst[key] = params[key]; // } }, fromId(id: string) { return createInst(id); }, // Type Boilerplate TsType: {}, _mfs_isRequiredOnCreate: true, get schema() { return getSchema(() => ({})); }, }; }, } as any; }, }; } // export type tableFormatTag = typeof tableFormatTag; export const tableFormatTag = `table` as const; type isArchivedKey = typeof isArchivedKey; const isArchivedKey = `$mfs.isArchived` as const; export type StartCloudSynchronizer = (watcherConfig: { path: string; initialCheckpoint: Checkpoint | null; setCheckpoint: (newCheckpoint: Checkpoint) => void; getChanges: ( onPushComplete: Promise, ) => DeepReadonlyJson; updateLocalDocs: (updates: DeepReadonlyJson) => void; }) => { requestPush: () => void; dispose: () => void; }; type InstFor = { readonly id: string; readonly isArchived: boolean; archive(): void; } & { [K in keyof T]: T[K] extends { create(...args: any[]): any; TsType: infer U; } ? U | null | undefined : T[K] extends { readonly TsType: infer U } ? U : T[K]; }; type CreateParamsFor = { [K in RequiredKeys]: T[K] extends { readonly TsType: infer U } ? U : T[K]; } & { [K in OptionalKeys]?: T[K] extends { readonly TsType: infer U } ? U : T[K]; }; type RequiredKeys = { /* Getter's shouldn't ever have a `_mfs_isRequiredOnCreate` prop, and we skip all functions. */ [K in keyof T]: T[K] extends Function ? never : T[K] extends { readonly _mfs_isRequiredOnCreate: true } ? K : never; }[keyof T]; type OptionalKeys = { /* Getter's shouldn't ever have a `_mfs_isRequiredOnCreate` prop, and we skip all functions. */ [K in keyof T]: T[K] extends Function ? never : T[K] extends { readonly _mfs_isRequiredOnCreate: false } ? K : never; }[keyof T];