From 9e71a3a887d5f45fcc35638fe94edf7b6a22678e Mon Sep 17 00:00:00 2001 From: Alex Shaw Date: Thu, 20 Nov 2025 12:39:26 +0000 Subject: [PATCH 1/3] WIP of a simpler IndexedDB solution: removed partial updates, simplified code, not removed yjs yet --- src/App.tsx | 5 +- src/pages/NewPage.tsx | 160 +++++++++--------- .../ProjectHistoryModal.tsx | 87 ++++++++++ src/project-persistence/ProjectItem.tsx | 114 +++++++++++++ .../ProjectStorageProvider.tsx | 100 +++++++++++ .../RenameProjectModal.tsx | 61 +++++++ src/project-persistence/project-history-db.ts | 57 +++++++ .../project-history-hooks.ts | 116 +++++++++++++ src/project-persistence/project-hooks.ts | 12 ++ src/project-persistence/project-list-db.ts | 82 +++++++++ src/project-persistence/project-list-hooks.ts | 117 +++++++++++++ src/project-persistence/project-store-yjs.ts | 78 +++++++++ src/project-persistence/utils.ts | 51 ++++++ src/store-persistence-hooks.ts | 29 ++++ src/store-persistence.ts | 65 +++++++ src/store.ts | 3 + 16 files changed, 1060 insertions(+), 77 deletions(-) create mode 100644 src/project-persistence/ProjectHistoryModal.tsx create mode 100644 src/project-persistence/ProjectItem.tsx create mode 100644 src/project-persistence/ProjectStorageProvider.tsx create mode 100644 src/project-persistence/RenameProjectModal.tsx create mode 100644 src/project-persistence/project-history-db.ts create mode 100644 src/project-persistence/project-history-hooks.ts create mode 100644 src/project-persistence/project-hooks.ts create mode 100644 src/project-persistence/project-list-db.ts create mode 100644 src/project-persistence/project-list-hooks.ts create mode 100644 src/project-persistence/project-store-yjs.ts create mode 100644 src/project-persistence/utils.ts create mode 100644 src/store-persistence-hooks.ts create mode 100644 src/store-persistence.ts diff --git a/src/App.tsx b/src/App.tsx index 838020655..aad8011ec 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -57,6 +57,7 @@ import { createNewPageUrl, createTestingModelPageUrl, } from "./urls"; +import { ProjectStorageProvider } from "./project-persistence/ProjectStorageProvider"; export interface ProviderLayoutProps { children: ReactNode; @@ -93,7 +94,9 @@ const Providers = ({ children }: ProviderLayoutProps) => { - {children} + + {children} + diff --git a/src/pages/NewPage.tsx b/src/pages/NewPage.tsx index 5c66d2bf7..1d406f0cb 100644 --- a/src/pages/NewPage.tsx +++ b/src/pages/NewPage.tsx @@ -5,17 +5,16 @@ * SPDX-License-Identifier: MIT */ import { - Box, Container, + Grid, Heading, HStack, Icon, - Stack, Text, VStack, } from "@chakra-ui/react"; -import { ReactNode, useCallback, useRef } from "react"; -import { RiAddLine, RiFolderOpenLine, RiRestartLine } from "react-icons/ri"; +import { useCallback, useRef, useState } from "react"; +import { RiAddLine, RiFolderOpenLine } from "react-icons/ri"; import { FormattedMessage, useIntl } from "react-intl"; import { useNavigate } from "react-router"; import DefaultPageLayout, { @@ -29,39 +28,65 @@ import NewPageChoice from "../components/NewPageChoice"; import { useLogging } from "../logging/logging-hooks"; import { useStore } from "../store"; import { createDataSamplesPageUrl } from "../urls"; -import { useProjectName } from "../hooks/project-hooks"; +import { useStoreProjects } from "../store-persistence-hooks"; +import { ProjectItem } from "../project-persistence/ProjectItem"; +import ProjectHistoryModal from "../project-persistence/ProjectHistoryModal"; +import { ProjectEntry } from "../project-persistence/project-list-db"; +import RenameProjectModal from "../project-persistence/RenameProjectModal"; +import { useProjectList } from "../project-persistence/project-list-hooks"; +import { useProjectHistory } from "../project-persistence/project-history-hooks"; const NewPage = () => { - const existingSessionTimestamp = useStore((s) => s.timestamp); - const projectName = useProjectName(); const newSession = useStore((s) => s.newSession); const navigate = useNavigate(); const logging = useLogging(); + const { loadProject, newProject } = useStoreProjects(); + const [showProjectHistory, setShowProjectHistory] = + useState(null); + const [showProjectRename, setShowProjectRename] = + useState(null); - const handleOpenLastSession = useCallback(() => { - logging.event({ - type: "session-open-last", - }); - navigate(createDataSamplesPageUrl()); - }, [logging, navigate]); + const { projectList, deleteProject, setProjectName } = useProjectList(); + const { loadRevision } = useProjectHistory(); + + const handleOpenSession = useCallback( + async (projectId: string) => { + logging.event({ + type: "session-open-saved", + }); + await loadProject(projectId); + navigate(createDataSamplesPageUrl()); + }, + [logging, navigate, loadProject] + ); + + const handleOpenRevision = useCallback( + async (projectId: string, revisionId: string) => { + logging.event({ + type: "session-open-revision", + }); + + await loadRevision(projectId, revisionId); + navigate(createDataSamplesPageUrl()); + }, + [logging, navigate, loadRevision] + ); const loadProjectRef = useRef(null); const handleContinueSessionFromFile = useCallback(() => { loadProjectRef.current?.chooseFile(); }, []); - const handleStartNewSession = useCallback(() => { + const handleStartNewSession = useCallback(async () => { logging.event({ type: "session-open-new", }); + await newProject(); newSession(); navigate(createDataSamplesPageUrl()); - }, [logging, newSession, navigate]); + }, [logging, newSession, navigate, newProject]); const intl = useIntl(); - const lastSessionTitle = intl.formatMessage({ - id: "newpage-last-session-title", - }); const continueSessionTitle = intl.formatMessage({ id: "newpage-continue-session-title", }); @@ -92,47 +117,14 @@ const NewPage = () => { flexDir={{ base: "column", lg: "row" }} > } + onClick={handleStartNewSession} + label={newSessionTitle} + disabled={false} + icon={} > - {existingSessionTimestamp ? ( - - - ( - - {chunks} - - ), - name: projectName, - }} - /> - - - ( - - {chunks} - - ), - date: new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - }).format(existingSessionTimestamp), - }} - /> - - - ) : ( - - - - )} + + + { + - + Your projects - - } - > - - - - - - + {projectList?.map((proj) => ( + { + void handleOpenSession(proj.id); + }} + deleteProject={deleteProject} + renameProject={() => setShowProjectRename(proj)} + showHistory={() => setShowProjectHistory(proj)} + /> + ))} + + setShowProjectHistory(null)} + projectInfo={showProjectHistory} + /> + setShowProjectRename(null)} + projectInfo={showProjectRename} + handleRename={async (projectId, projectName) => { + await setProjectName(projectId, projectName); + setShowProjectRename(null); + }} + /> ); }; diff --git a/src/project-persistence/ProjectHistoryModal.tsx b/src/project-persistence/ProjectHistoryModal.tsx new file mode 100644 index 000000000..be6a397ee --- /dev/null +++ b/src/project-persistence/ProjectHistoryModal.tsx @@ -0,0 +1,87 @@ +import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, VStack, List, ListItem, Heading, Button, ModalFooter } from "@chakra-ui/react"; +import { HistoryList } from "./project-history-db"; +import { ProjectEntry } from "./project-list-db"; +import { useCallback, useEffect, useState } from "react"; +import { significantDateUnits } from "./utils"; +import { useProjectHistory } from "./project-history-hooks"; + +interface ProjectHistoryModalProps { + onLoadRequest: (projectId: string, revisionId: string) => void; + isOpen: boolean; + onDismiss: () => void; + projectInfo: ProjectEntry | null; +} + +const ProjectHistoryModal = ({ + onLoadRequest, + isOpen, + onDismiss, + projectInfo, +}: ProjectHistoryModalProps) => { + const [projectHistoryList, setProjectHistoryList] = + useState(null); + const { getHistory, saveRevision } = useProjectHistory(); + + const getProjectHistory = useCallback(async () => { + if (projectInfo === null) { + setProjectHistoryList(null); + return; + } + const historyList = await getHistory(projectInfo.id); + setProjectHistoryList(historyList.sort((h) => -h.timestamp)); + }, [getHistory, projectInfo]); + + useEffect(() => { + void getProjectHistory(); + }, [projectInfo, getProjectHistory]); + + return ( + + + + Project history + + + {projectInfo && ( + + {projectInfo.projectName} + + + Latest + + + {projectHistoryList?.map((ph) => ( + + + Saved on {significantDateUnits(new Date(ph.timestamp))} + + + + ))} + + + )} + + + + + + + + ); +}; + +export default ProjectHistoryModal; \ No newline at end of file diff --git a/src/project-persistence/ProjectItem.tsx b/src/project-persistence/ProjectItem.tsx new file mode 100644 index 000000000..c1a4be901 --- /dev/null +++ b/src/project-persistence/ProjectItem.tsx @@ -0,0 +1,114 @@ +import { + CloseButton, + GridItem, + Heading, + HStack, + IconButton, + Text, +} from "@chakra-ui/react"; +import { ReactNode } from "react"; +import { ProjectEntry } from "./project-list-db"; +import { timeAgo } from "./utils"; +import { RiEditFill, RiHistoryFill } from "react-icons/ri"; + +interface ProjectItemProps { + project: ProjectEntry; + showHistory: (projectId: string) => void; + loadProject: (projectId: string) => void; + deleteProject: (projectId: string) => void; + renameProject: (projectId: string) => void; +} + +interface ProjectItemBaseProps { + children: ReactNode; + onClick: () => void; +} + +const ProjectItemBase = ({ onClick, children }: ProjectItemBaseProps) => ( + + {children} + +); + +export const ProjectItem = ({ + project, + loadProject, + deleteProject, + renameProject, + showHistory, +}: ProjectItemProps) => ( + loadProject(project.id)}> + + + {project.projectName} + + + {timeAgo(new Date(project.modifiedDate))} + + } + mr="2" + onClick={(e) => { + showHistory(project.id); + e.stopPropagation(); + e.preventDefault(); + }} + size="lg" + title="Project history" + variant="outline" + /> + } + onClick={(e) => { + renameProject(project.id); + e.stopPropagation(); + e.preventDefault(); + }} + size="lg" + title="Rename" + variant="outline" + /> + { + deleteProject(project.id); + e.stopPropagation(); + e.preventDefault(); + }} + /> + +); + +interface AddProjectItemProps { + newProject: () => void; +} + +export const AddProjectItem = ({ newProject }: AddProjectItemProps) => ( + + + New project + + Click to create + +); diff --git a/src/project-persistence/ProjectStorageProvider.tsx b/src/project-persistence/ProjectStorageProvider.tsx new file mode 100644 index 000000000..e2d99de4a --- /dev/null +++ b/src/project-persistence/ProjectStorageProvider.tsx @@ -0,0 +1,100 @@ +// ProjectContext.tsx +import React, { createContext, useCallback, useContext, useState } from "react"; +import { ProjectList } from "./project-list-db"; +import { DocAccessor } from "./project-list-hooks"; +import { ProjectStoreYjs } from "./project-store-yjs"; + +interface ProjectContextValue { + projectId: string | null; + projectList: ProjectList | null; + setProjectList: (projectList: ProjectList) => void; + projectAccessor: DocAccessor | null; + setProjectAccessor: (accessor: DocAccessor) => void; + openProject: ( + projectId: string, + onChangeObserver: () => void + ) => Promise; +} + +export const ProjectStorageContext = createContext( + null +); + +interface DocAccessorInternal extends DocAccessor { + destroy: () => void; + projectId: string; +} + +/** + * The ProjectStorageProvider is intended to be used only through the hooks in + * + * - project-list-hooks.ts: information about hooks that does not require an open project + * - persistent-project-hooks.ts: manages a currently open project + * - project-history-hooks.ts: manages project history and revisions + * + * This structure is helpful for working out what parts of project persistence are used + * where. + */ +export function ProjectStorageProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [projectList, setProjectList] = useState(null); + const [projectAccessor, setProjectAccessorImpl] = + useState(null); + const projectAccessorInternal = projectAccessor as DocAccessorInternal; + const setProjectAccessor = useCallback( + (newProjectStore: DocAccessor) => { + if (projectAccessor) { + projectAccessorInternal.destroy(); + } + setProjectAccessorImpl(newProjectStore); + }, + [projectAccessor] + ); + + const openProject = async ( + projectId: string, + onChangeObserver: () => void + ) => { + const newProjectStore = new ProjectStoreYjs(projectId, onChangeObserver); + await newProjectStore.persist(); + newProjectStore.startSyncing(); + const newProjectAccessor: DocAccessorInternal = { + setDoc: (doc: string) => { + const t = newProjectStore.ydoc.getText(); + t.delete(0, t.length); + t.insert(0, doc); + }, + getDoc: () => newProjectStore.ydoc.getText().toJSON(), + destroy: () => newProjectStore.destroy(), + projectId: newProjectStore.projectId, + }; + return newProjectAccessor; + }; + + return ( + + {children} + + ); +} + +export function useProjectStorage() { + const ctx = useContext(ProjectStorageContext); + if (!ctx) + throw new Error( + "useProjectStorage must be used within a ProjectStorageProvider" + ); + return ctx; +} diff --git a/src/project-persistence/RenameProjectModal.tsx b/src/project-persistence/RenameProjectModal.tsx new file mode 100644 index 000000000..003752329 --- /dev/null +++ b/src/project-persistence/RenameProjectModal.tsx @@ -0,0 +1,61 @@ +import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, VStack, Button, ModalFooter, Input } from "@chakra-ui/react"; +import { ProjectEntry } from "./project-list-db"; +import { useEffect, useState } from "react"; + +interface ProjectHistoryModalProps { + handleRename: (projectId: string, projectName: string) => void; + isOpen: boolean; + onDismiss: () => void; + projectInfo: ProjectEntry | null; +} + +const RenameProjectModal = ({ + handleRename, + isOpen, + onDismiss, + projectInfo +}: ProjectHistoryModalProps) => { + const [projectName, setProjectName] = useState(projectInfo?.projectName || ""); + + useEffect(() => { + if (!projectInfo) { + return; + } + setProjectName(projectInfo.projectName); + }, [projectInfo]); + + return ( + + + Project history + + + {projectInfo && ( + + setProjectName(e.target.value)} /> + )} + + + + + + + + ) + } + +export default RenameProjectModal; \ No newline at end of file diff --git a/src/project-persistence/project-history-db.ts b/src/project-persistence/project-history-db.ts new file mode 100644 index 000000000..10764cf7b --- /dev/null +++ b/src/project-persistence/project-history-db.ts @@ -0,0 +1,57 @@ + +export interface HistoryEntry { + projectId: string; + revisionId: string; + parentId: string; + data: Uint8Array; + timestamp: number; +} + +export type HistoryList = HistoryEntry[]; + +type HistoryDbWrapper = ( + accessMode: "readonly" | "readwrite", + callback: (revisions: IDBObjectStore) => Promise +) => Promise; + +export const withHistoryDb: HistoryDbWrapper = async (accessMode, callback) => { + return new Promise((res, rej) => { + const openRequest = indexedDB.open("UserProjectHistory", 1); + openRequest.onupgradeneeded = (evt: IDBVersionChangeEvent) => { + const db = openRequest.result; + const tx = (evt.target as IDBOpenDBRequest).transaction; + + let revisions: IDBObjectStore; + if (!db.objectStoreNames.contains("revisions")) { + revisions = db.createObjectStore("revisions", { autoIncrement:true }); + } else { + revisions = tx!.objectStore("revisions"); + } + if (!revisions.indexNames.contains("projectRevision")) { + revisions.createIndex("projectRevision", ["projectId", "revisionId"]); + } + if (!revisions.indexNames.contains("projectParent")) { + revisions.createIndex("projectParent", ["projectId", "parentId"]); + } + if (!revisions.indexNames.contains("projectId")) { + revisions.createIndex("projectId", "projectId"); + } + }; + + openRequest.onsuccess = async () => { + const db = openRequest.result; + + const tx = db.transaction("revisions", accessMode); + const store = tx.objectStore("revisions"); + tx.onabort = rej; + tx.onerror = rej; + + const result = await callback(store); + + // got the result, but don't return until the transaction is complete + tx.oncomplete = () => res(result); + }; + + openRequest.onerror = rej; + }); +}; diff --git a/src/project-persistence/project-history-hooks.ts b/src/project-persistence/project-history-hooks.ts new file mode 100644 index 000000000..346e340fe --- /dev/null +++ b/src/project-persistence/project-history-hooks.ts @@ -0,0 +1,116 @@ +import { useCallback, useContext } from "react"; +import { ProjectStorageContext } from "./ProjectStorageProvider"; +import { HistoryEntry, HistoryList, withHistoryDb } from "./project-history-db"; +import { modifyProject, ProjectEntry, withProjectDb } from "./project-list-db"; +import { makeUID } from "./utils"; +import * as Y from "yjs"; +import { ProjectStoreYjs } from "./project-store-yjs"; +import { useProjectList } from "./project-list-hooks"; + +/** + * Each project has a "head" which is a Y.Doc, and a series of revisions which are Y.js Update deltas. + */ +interface ProjectHistoryActions { + getHistory: (projectId: string) => Promise; + /** + * Note that loading a revision creates a new instance of the project at that revision. + * + * TODO: if a user loads a revision and doesn't modify it, should we even keep it around? + */ + loadRevision: (projectId: string, projectRevision: string) => Promise; + /** + * Converts the head of the given project into a revision. + * + * TODO: prevent creating empty revisions if nothing changes. + */ + saveRevision: (projectInfo: ProjectEntry) => Promise; +} + +export const useProjectHistory = (): ProjectHistoryActions => { + const ctx = useContext(ProjectStorageContext); + if (!ctx) { + throw new Error( + "useProjectHistory must be used within a ProjectStorageProvider" + ); + } + const { newStoredProject } = useProjectList(); + + const getUpdateAtRevision = useCallback(async (projectId: string, revision: string) => { + const deltas: HistoryEntry[] = []; + let parentRevision = revision; + do { + const delta = await withHistoryDb("readonly", async (revisions) => { + return new Promise((res, _rej) => { + const query = revisions + .index("projectRevision") + .get([projectId, parentRevision]); + query.onsuccess = () => res(query.result as HistoryEntry); + }); + }); + parentRevision = delta.parentId; + deltas.unshift(delta); + } while (parentRevision); + return Y.mergeUpdatesV2(deltas.map((d) => d.data)); + }, []); + + const getProjectInfo = (projectId: string) => + withProjectDb("readwrite", async (store) => { + return new Promise((res, _rej) => { + const query = store.get(projectId); + query.onsuccess = () => res(query.result as ProjectEntry); + }); + }); + + const loadRevision = useCallback(async (projectId: string, projectRevision: string) => { + const projectInfo = await getProjectInfo(projectId); + const { doc, id: forkId } = await newStoredProject(); + await modifyProject(forkId, { + projectName: `${projectInfo.projectName} revision`, + parentRevision: forkId, + }); + const updates = await getUpdateAtRevision(projectId, projectRevision); + // TODO: I broke history! + //Y.applyUpdateV2(ydoc, updates); + }, [getUpdateAtRevision, newStoredProject]); + + const saveRevision = useCallback(async (projectInfo: ProjectEntry) => { + const projectStore = new ProjectStoreYjs(projectInfo.id, () => { }); + await projectStore.persist(); + let newUpdate: Uint8Array; + if (projectInfo.parentRevision) { + const previousUpdate = await getUpdateAtRevision( + projectInfo.id, + projectInfo.parentRevision + ); + newUpdate = Y.encodeStateAsUpdateV2(projectStore.ydoc, previousUpdate); + } else { + newUpdate = Y.encodeStateAsUpdateV2(projectStore.ydoc); + } + const newRevision = makeUID(); + await withHistoryDb("readwrite", async (revisions) => { + return new Promise((res, _rej) => { + const query = revisions.put({ + projectId: projectInfo.id, + revisionId: newRevision, + parentId: projectInfo.parentRevision, + data: newUpdate, + timestamp: new Date(), + }); + query.onsuccess = () => res(); + }); + }); + await modifyProject(projectInfo.id, { parentRevision: newRevision }); + }, [getUpdateAtRevision]); + + const getHistory = useCallback(async (projectId: string) => + withHistoryDb("readonly", async (store) => { + const revisionList = await new Promise((res, _rej) => { + const query = store.index("projectId").getAll(projectId); + query.onsuccess = () => res(query.result); + }); + return revisionList; + }), []); + + + return { getHistory, loadRevision, saveRevision }; +} diff --git a/src/project-persistence/project-hooks.ts b/src/project-persistence/project-hooks.ts new file mode 100644 index 000000000..393365580 --- /dev/null +++ b/src/project-persistence/project-hooks.ts @@ -0,0 +1,12 @@ +import { useContext } from "react"; +import { ProjectStorageContext } from "./ProjectStorageProvider"; + +export const usePersistentProject = () => { + + const ctx = useContext(ProjectStorageContext); + if (!ctx) + throw new Error( + "usePersistentProject must be used within a ProjectStorageProvider" + ); + return ctx.projectAccessor; +} diff --git a/src/project-persistence/project-list-db.ts b/src/project-persistence/project-list-db.ts new file mode 100644 index 000000000..3a937e70e --- /dev/null +++ b/src/project-persistence/project-list-db.ts @@ -0,0 +1,82 @@ + +export interface ProjectEntry { + projectName: string; + id: string; + modifiedDate: number; + parentRevision?: string; +} + +export type ProjectList = ProjectEntry[]; + +type ProjectDbWrapper = ( + accessMode: "readonly" | "readwrite", + callback: (projects: IDBObjectStore) => Promise +) => Promise; + +export const withProjectDb: ProjectDbWrapper = async (accessMode, callback) => { + return new Promise((res, rej) => { + // TODO: what if multiple users? I think MakeCode just keeps everything... + const openRequest = indexedDB.open("UserProjects", 2); + openRequest.onupgradeneeded = (evt: IDBVersionChangeEvent) => { + const db = openRequest.result; + // NB: a more robust way to write migrations would be to get the current stored + // db.version and open it repeatedly with an ascending version number until the + // db is up to date. That would be more boilerplate though. + const tx = (evt.target as IDBOpenDBRequest).transaction; + // if the data object store doesn't exist, create it + + let projects: IDBObjectStore; + if (!db.objectStoreNames.contains("projects")) { + projects = db.createObjectStore("projects", { keyPath: "id" }); + // no indexes at present, get the whole db each time + } else { + projects = tx!.objectStore("projects"); + } + if (!projects.indexNames.contains("modifiedDate")) { + projects.createIndex("modifiedDate", "modifiedDate"); + const now = new Date().valueOf(); + const updateProjectData = projects.getAll(); + updateProjectData.onsuccess = () => { + updateProjectData.result.forEach((project) => { + if (!('modifiedDate' in project)) { + projects.put({ ...project, modifiedDate: now }); + } + }); + }; + } + }; + + openRequest.onsuccess = async () => { + const db = openRequest.result; + + const tx = db.transaction("projects", accessMode); + const store = tx.objectStore("projects"); + tx.onabort = rej; + tx.onerror = rej; + + const result = await callback(store); + + // got the result, but don't return until the transaction is complete + tx.oncomplete = () => res(result); + }; + + openRequest.onerror = rej; + }); +}; + + +export const modifyProject = async (id: string, extras?: Partial) => { + await withProjectDb("readwrite", async (store) => { + await new Promise((res, _rej) => { + const getQuery = store.get(id); + getQuery.onsuccess = () => { + const putQuery = store.put({ + ...getQuery.result, + ...extras, + modifiedDate: new Date().valueOf(), + }); + putQuery.onsuccess = () => res(getQuery.result); + }; + }); + }); +} diff --git a/src/project-persistence/project-list-hooks.ts b/src/project-persistence/project-list-hooks.ts new file mode 100644 index 000000000..e7578c5d8 --- /dev/null +++ b/src/project-persistence/project-list-hooks.ts @@ -0,0 +1,117 @@ +import { useCallback, useContext, useEffect } from "react"; +import { ProjectStorageContext } from "./ProjectStorageProvider"; +import { modifyProject, ProjectList, withProjectDb } from "./project-list-db"; +import { makeUID } from "./utils"; + +export interface DocAccessor { + setDoc(doc: string): void; + getDoc(): string; +} + +export interface NewStoredDoc { + id: string; + doc: DocAccessor; +} + +export interface RestoredStoredDoc { + projectName: string; + doc: DocAccessor; +} + +interface ProjectListActions { + newStoredProject: () => Promise; + restoreStoredProject: (id: string) => Promise; + deleteProject: (id: string) => Promise; + setProjectName: (id: string, name: string) => Promise; + projectList: ProjectList | null; +} + +export const useProjectList = (): ProjectListActions => { + + const ctx = useContext(ProjectStorageContext); + + if (!ctx) { + throw new Error( + "useProjectList must be used within a ProjectStorageProvider" + ); + } + + const { setProjectList, projectList, setProjectAccessor, openProject } = ctx; + + const refreshProjects = useCallback(async () => { + const projectList = await withProjectDb("readonly", async (store) => { + const projectList = await new Promise((res, _rej) => { + const query = store.index("modifiedDate").getAll(); + query.onsuccess = () => res(query.result); + }); + return projectList; + }); + setProjectList((projectList as ProjectList).reverse()); + }, [setProjectList]); + + useEffect(() => { + if (window.navigator.storage?.persist) { + void window.navigator.storage.persist(); + } + void refreshProjects(); + }, [refreshProjects]); + + const setProjectName = useCallback( + async (id: string, projectName: string) => { + await modifyProject(id, { projectName }); + await refreshProjects(); + }, + [refreshProjects] + ); + + const restoreStoredProject: ( + projectId: string + ) => Promise = useCallback( + async (projectId: string) => { + const newProjectStore = await openProject(projectId, () => modifyProject(projectId)); + setProjectAccessor(newProjectStore); + return { + doc: newProjectStore, + projectName: projectList!.find((prj) => prj.id === projectId)! + .projectName, + }; + }, + [projectList, setProjectAccessor] + ); + + const newStoredProject: () => Promise = + useCallback(async () => { + const newProjectId = makeUID(); + await withProjectDb("readwrite", async (store) => { + store.add({ + id: newProjectId, + projectName: "Untitled project", + modifiedDate: new Date().valueOf(), + }); + return Promise.resolve(); + }); + const newProjectStore = await openProject(newProjectId, () => + modifyProject(newProjectId) + ); + setProjectAccessor(newProjectStore); + return { doc: newProjectStore, id: newProjectId }; + }, [setProjectAccessor]); + + const deleteProject: (id: string) => Promise = useCallback( + async (id) => { + await withProjectDb("readwrite", async (store) => { + store.delete(id); + return refreshProjects(); + }); + }, + [refreshProjects] + ); + + return { + restoreStoredProject, + newStoredProject, + deleteProject, + setProjectName, + projectList + }; +} diff --git a/src/project-persistence/project-store-yjs.ts b/src/project-persistence/project-store-yjs.ts new file mode 100644 index 000000000..4dccf7f6b --- /dev/null +++ b/src/project-persistence/project-store-yjs.ts @@ -0,0 +1,78 @@ +import { IndexeddbPersistence } from "y-indexeddb"; +import { Awareness } from "y-protocols/awareness.js"; +import * as Y from "yjs"; + +/** + * Because the ydoc persistence/sync needs to clean itself up from time to time + * it is in a class with the following state. It is agnostic in itself whether the project with the + * specified UID exists. + * + * constructor - sets up the state + * init - connects the persistence store, and local sync broadcast. Asynchronous, so you can await it + * destroy - disconnects everything that was connected in init, cleans up the persistence store + */ +export class ProjectStoreYjs { + public ydoc: Y.Doc; + public awareness: Awareness; + private broadcastHandler: (e: MessageEvent) => void; + private persistence: IndexeddbPersistence; + private updates: BroadcastChannel; + private updatePoster: (update: Uint8Array) => void; + + constructor(public projectId: string, projectChangedListener: () => void) { + const ydoc = new Y.Doc(); + this.ydoc = ydoc; + this.awareness = new Awareness(this.ydoc); + + this.persistence = new IndexeddbPersistence(this.projectId, this.ydoc); + + const clientId = `${Math.random()}`; // Used by the broadcasthandler to know whether we sent a data update + this.broadcastHandler = ({ data }: MessageEvent) => { + if (data.clientId !== clientId && data.projectId === projectId) { + Y.applyUpdate(ydoc, data.update); + } + }; + + this.updates = new BroadcastChannel("yjs"); + this.updatePoster = ((update: Uint8Array) => { + this.updates.postMessage({ clientId, update, projectId }); + projectChangedListener(); + }).bind(this); + } + + public async persist() { + await new Promise((res) => this.persistence.once("synced", res)); + migrate(this.ydoc); + } + + public startSyncing() { + this.ydoc.on("update", this.updatePoster); + this.updates.addEventListener("message", this.broadcastHandler); + } + + public destroy() { + this.ydoc.off("update", this.updatePoster); + this.updates.removeEventListener("message", this.broadcastHandler); + this.updates.close(); + void this.persistence.destroy(); + } +} + +/** + * This is a kind of example of what migration could look like. It's not a designed approach at this point. + */ +const migrate = (doc: Y.Doc) => { + const meta = doc.getMap("meta"); + if (!meta.has("version")) { + // If the project has no version, assume it's from whatever this app did before ProjectStorageProvider + // This could be a per-app handler + meta.set("version", 1); + meta.set("projectName", "default"); // TODO: get this from the last loaded project name + } +}; + +interface SyncMessage { + clientId: string; + projectId: string; + update: Uint8Array; +} \ No newline at end of file diff --git a/src/project-persistence/utils.ts b/src/project-persistence/utils.ts new file mode 100644 index 000000000..bc3a774cf --- /dev/null +++ b/src/project-persistence/utils.ts @@ -0,0 +1,51 @@ +export function timeAgo(date: Date): string { + const now = new Date(); + const seconds = Math.round((+now - +date) / 1000); + const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); + + const divisions: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [ + { amount: 60, unit: 'second' }, + { amount: 60, unit: 'minute' }, + { amount: 24, unit: 'hour' }, + { amount: 7, unit: 'day' }, + { amount: 4.34524, unit: 'week' }, // approx + { amount: 12, unit: 'month' } + ]; + + let duration = seconds; + for (const division of divisions) { + if (Math.abs(duration) < division.amount) { + return rtf.format(-Math.round(duration), division.unit); + } + duration /= division.amount; + } + + return rtf.format(-Math.round(duration), "year"); +} + +export function significantDateUnits(date: Date): string { + const now = new Date(); + + let dateTimeOptions: Intl.DateTimeFormatOptions = { month: "short", year: "2-digit" }; + + const daysDifferent = Math.round((+now - +date) / (1000 * 60 * 60 * 24)); + if (daysDifferent < 1 && date.getDay() === now.getDay()) { + dateTimeOptions = { + hour: 'numeric', + minute: 'numeric', + } + } else if (now.getFullYear() === date.getFullYear()) { + dateTimeOptions = { + day: 'numeric', + month: 'short' + } + } + + return Intl.DateTimeFormat(undefined, dateTimeOptions).format(date); +} + +// TODO: WORLDS UGLIEST UIDS +export const makeUID = () => { + return `${Math.random()}`; +}; + diff --git a/src/store-persistence-hooks.ts b/src/store-persistence-hooks.ts new file mode 100644 index 000000000..6ff580972 --- /dev/null +++ b/src/store-persistence-hooks.ts @@ -0,0 +1,29 @@ +import { useStore } from "./store"; +import { loadNewDoc } from "./store-persistence"; +import { useProjectList } from "./project-persistence/project-list-hooks"; + +export const useStoreProjects = () => { + // storeprojects relates to projects of type Store + // projectstorage stores projects + // simple? + // TODO: improve naming + const { newStoredProject, restoreStoredProject } = useProjectList(); + const newProject = async () => { + const newProjectImpl = async () => { + const { doc } = await newStoredProject(); + return doc; + } + await loadNewDoc(newProjectImpl()); + // Needed to attach Y types + await useStore.persist.rehydrate(); + } + const loadProject = async (projectId: string) => { + const loadProjectImpl = async () => { + const { doc } = await restoreStoredProject(projectId); + return doc; + } + await loadNewDoc(loadProjectImpl()); + await useStore.persist.rehydrate(); + } + return { loadProject, newProject }; +} diff --git a/src/store-persistence.ts b/src/store-persistence.ts new file mode 100644 index 000000000..93b4313c0 --- /dev/null +++ b/src/store-persistence.ts @@ -0,0 +1,65 @@ +import { PersistStorage, StorageValue } from "zustand/middleware"; +import { DocAccessor } from "./project-persistence/project-list-hooks"; + + +interface ProjectState { + doc: DocAccessor | null; + loadingPromise: Promise | null; +} + +const activeState: ProjectState = { + doc: null, + loadingPromise: null +} + +export const loadNewDoc = async (loadingPromise: Promise) => { + activeState.doc = null; + activeState.loadingPromise = loadingPromise; + activeState.doc = await loadingPromise; +} + +// This storage system ignores that Zustand supports multiple datastores, because it exists +// in the specific context that we want to blend the existing Zustand data with a Y.js +// backend, and we know what the data should be so genericism goes out of the window. +// Anything that is not handled as a special case (e.g. actions are special) becomes a +// simple json doc in the same way that Zustand persist conventionally does it. +export const BASE_DOC_NAME = "ml"; + +// TODO: Think about what versioning should relate to. +// The zustand store has a version, and this also has structurally-sensitive things in +// its storeState mapper. +// store.ts currently has a lot of controller logic, and it could be pared out and synced +// more loosely with the yjs-ified data. E.g. project syncing could be done at a level above +// the store, with a subscription. +export const projectStorage = () => { + const getItem = (_name: string) => { + if (activeState.doc) { + return JSON.parse(activeState.doc.getDoc()) as T; + } else { + return async () => { + await activeState.loadingPromise; + return JSON.parse(activeState.doc!.getDoc()) as T; + } + } + } + + const setItem = (_name: string, value: StorageValue) => { + if (activeState.doc) { + activeState.doc.setDoc(JSON.stringify(value)); + } else { + return async () => { + await activeState.loadingPromise; + activeState.doc!.setDoc(JSON.stringify(value)); + } + } + } + const removeItem = (_name: string) => { + // Don't remove things through Zustand, use ProjectStorage + } + + return { + getItem, + setItem, + removeItem + } as PersistStorage; +} \ No newline at end of file diff --git a/src/store.ts b/src/store.ts index 6120e675a..c908d48ab 100644 --- a/src/store.ts +++ b/src/store.ts @@ -45,6 +45,7 @@ import { BufferedData } from "./buffered-data"; import { getDetectedAction } from "./utils/prediction"; import { getTour as getTourSpec } from "./tours"; import { createPromise, PromiseInfo } from "./hooks/use-promise-ref"; +import { projectStorage } from "./store-persistence"; export const modelUrl = "indexeddb://micro:bit-ai-creator-model"; @@ -1312,6 +1313,8 @@ const createMlStore = (logging: Logging) => { }, }; }, + storage: projectStorage(), + skipHydration: true } ), { enabled: flags.devtools } From d6409afdc697bd15f2089845cd5d8b95a8a00cbd Mon Sep 17 00:00:00 2001 From: Alex Shaw Date: Fri, 21 Nov 2025 11:00:05 +0000 Subject: [PATCH 2/3] WIP partway through IDB storage --- .../ProjectStorageProvider.tsx | 17 ++-- src/project-persistence/project-list-db.ts | 86 +++++++++++-------- src/project-persistence/project-list-hooks.ts | 4 +- src/project-persistence/project-store-idb.ts | 16 ++++ src/store-persistence-hooks.ts | 1 - src/store-persistence.ts | 11 ++- 6 files changed, 86 insertions(+), 49 deletions(-) create mode 100644 src/project-persistence/project-store-idb.ts diff --git a/src/project-persistence/ProjectStorageProvider.tsx b/src/project-persistence/ProjectStorageProvider.tsx index e2d99de4a..b62c8b4b0 100644 --- a/src/project-persistence/ProjectStorageProvider.tsx +++ b/src/project-persistence/ProjectStorageProvider.tsx @@ -2,7 +2,7 @@ import React, { createContext, useCallback, useContext, useState } from "react"; import { ProjectList } from "./project-list-db"; import { DocAccessor } from "./project-list-hooks"; -import { ProjectStoreYjs } from "./project-store-yjs"; +import { writeProject, readProject } from "./project-store-idb"; interface ProjectContextValue { projectId: string | null; @@ -58,18 +58,15 @@ export function ProjectStorageProvider({ projectId: string, onChangeObserver: () => void ) => { - const newProjectStore = new ProjectStoreYjs(projectId, onChangeObserver); - await newProjectStore.persist(); - newProjectStore.startSyncing(); const newProjectAccessor: DocAccessorInternal = { setDoc: (doc: string) => { - const t = newProjectStore.ydoc.getText(); - t.delete(0, t.length); - t.insert(0, doc); + writeProject(projectId, doc); + onChangeObserver(); // Fine here, because we only use it to trigger modified behaviour, but... + // TODO: handle synchronisation from other DB changes }, - getDoc: () => newProjectStore.ydoc.getText().toJSON(), - destroy: () => newProjectStore.destroy(), - projectId: newProjectStore.projectId, + getDoc: () => readProject(projectId), + destroy: () => {}, + projectId, }; return newProjectAccessor; }; diff --git a/src/project-persistence/project-list-db.ts b/src/project-persistence/project-list-db.ts index 3a937e70e..f5b66c4c0 100644 --- a/src/project-persistence/project-list-db.ts +++ b/src/project-persistence/project-list-db.ts @@ -8,49 +8,33 @@ export interface ProjectEntry { export type ProjectList = ProjectEntry[]; +export interface ProjectData { + id: string; + contents: string; +} + +export enum ProjectDbTable { + Projects = "projects", + ProjectData = "projectData" +} + type ProjectDbWrapper = ( accessMode: "readonly" | "readwrite", - callback: (projects: IDBObjectStore) => Promise + callback: (projects: IDBObjectStore) => Promise, + tableName?: ProjectDbTable ) => Promise; -export const withProjectDb: ProjectDbWrapper = async (accessMode, callback) => { +export const withProjectDb: ProjectDbWrapper = async (accessMode, callback, tableName = ProjectDbTable.Projects) => { return new Promise((res, rej) => { // TODO: what if multiple users? I think MakeCode just keeps everything... - const openRequest = indexedDB.open("UserProjects", 2); - openRequest.onupgradeneeded = (evt: IDBVersionChangeEvent) => { - const db = openRequest.result; - // NB: a more robust way to write migrations would be to get the current stored - // db.version and open it repeatedly with an ascending version number until the - // db is up to date. That would be more boilerplate though. - const tx = (evt.target as IDBOpenDBRequest).transaction; - // if the data object store doesn't exist, create it - - let projects: IDBObjectStore; - if (!db.objectStoreNames.contains("projects")) { - projects = db.createObjectStore("projects", { keyPath: "id" }); - // no indexes at present, get the whole db each time - } else { - projects = tx!.objectStore("projects"); - } - if (!projects.indexNames.contains("modifiedDate")) { - projects.createIndex("modifiedDate", "modifiedDate"); - const now = new Date().valueOf(); - const updateProjectData = projects.getAll(); - updateProjectData.onsuccess = () => { - updateProjectData.result.forEach((project) => { - if (!('modifiedDate' in project)) { - projects.put({ ...project, modifiedDate: now }); - } - }); - }; - } - }; + const openRequest = indexedDB.open("UserProjects", 3); + openRequest.onupgradeneeded = onUpgradeNeeded(openRequest); openRequest.onsuccess = async () => { const db = openRequest.result; - const tx = db.transaction("projects", accessMode); - const store = tx.objectStore("projects"); + const tx = db.transaction(tableName, accessMode); + const store = tx.objectStore(tableName); tx.onabort = rej; tx.onerror = rej; @@ -64,7 +48,6 @@ export const withProjectDb: ProjectDbWrapper = async (accessMode, callback) => { }); }; - export const modifyProject = async (id: string, extras?: Partial) => { await withProjectDb("readwrite", async (store) => { await new Promise((res, _rej) => { @@ -80,3 +63,38 @@ export const modifyProject = async (id: string, extras?: Partial) }); }); } + +const onUpgradeNeeded = (openRequest: IDBOpenDBRequest) => (evt: IDBVersionChangeEvent) => { + const db = openRequest.result; + // NB: a more robust way to write migrations would be to get the current stored + // db.version and open it repeatedly with an ascending version number until the + // db is up to date. That would be more boilerplate though. + const tx = (evt.target as IDBOpenDBRequest).transaction; + // if the data object store doesn't exist, create it + + let projectListStore: IDBObjectStore; + if (!db.objectStoreNames.contains(ProjectDbTable.Projects)) { + projectListStore = db.createObjectStore(ProjectDbTable.Projects, { keyPath: "id" }); + } else { + projectListStore = tx!.objectStore(ProjectDbTable.Projects); + } + if (!projectListStore.indexNames.contains("modifiedDate")) { + projectListStore.createIndex("modifiedDate", "modifiedDate"); + const now = new Date().valueOf(); + const updateProjectData = projectListStore.getAll(); + updateProjectData.onsuccess = () => { + updateProjectData.result.forEach((project) => { + if (!('modifiedDate' in project)) { + projectListStore.put({ ...project, modifiedDate: now }); + } + }); + }; + } + + let projectDataStore: IDBObjectStore; + if (!db.objectStoreNames.contains(ProjectDbTable.ProjectData)) { + projectDataStore = db.createObjectStore(ProjectDbTable.ProjectData, { keyPath: "id" }); + } else { + projectDataStore = tx!.objectStore(ProjectDbTable.ProjectData); + } +}; diff --git a/src/project-persistence/project-list-hooks.ts b/src/project-persistence/project-list-hooks.ts index e7578c5d8..01f49446e 100644 --- a/src/project-persistence/project-list-hooks.ts +++ b/src/project-persistence/project-list-hooks.ts @@ -4,8 +4,8 @@ import { modifyProject, ProjectList, withProjectDb } from "./project-list-db"; import { makeUID } from "./utils"; export interface DocAccessor { - setDoc(doc: string): void; - getDoc(): string; + setDoc: (doc: string) => void | Promise; + getDoc: () => string | Promise; } export interface NewStoredDoc { diff --git a/src/project-persistence/project-store-idb.ts b/src/project-persistence/project-store-idb.ts new file mode 100644 index 000000000..84964f075 --- /dev/null +++ b/src/project-persistence/project-store-idb.ts @@ -0,0 +1,16 @@ +import { ProjectDbTable, withProjectDb } from "./project-list-db"; + +export const writeProject = (projectId: string, contents: string) => withProjectDb("readwrite", (projects) => + + new Promise((res, _rej) => { + const query = projects.put({ id: projectId, contents }) + query.onsuccess = () => res(); + }) + , ProjectDbTable.ProjectData); + +export const readProject = (projectId: string) => withProjectDb("readonly", (projects) => + new Promise((res, _rej) => { + const query = projects.get(projectId) + query.onsuccess = () => res(query.result as string); + }) + , ProjectDbTable.ProjectData); \ No newline at end of file diff --git a/src/store-persistence-hooks.ts b/src/store-persistence-hooks.ts index 6ff580972..c90a943f9 100644 --- a/src/store-persistence-hooks.ts +++ b/src/store-persistence-hooks.ts @@ -14,7 +14,6 @@ export const useStoreProjects = () => { return doc; } await loadNewDoc(newProjectImpl()); - // Needed to attach Y types await useStore.persist.rehydrate(); } const loadProject = async (projectId: string) => { diff --git a/src/store-persistence.ts b/src/store-persistence.ts index 93b4313c0..33ea9b339 100644 --- a/src/store-persistence.ts +++ b/src/store-persistence.ts @@ -33,12 +33,19 @@ export const BASE_DOC_NAME = "ml"; // the store, with a subscription. export const projectStorage = () => { const getItem = (_name: string) => { + const unpackResult = (result: string | Promise) => { + if (typeof result === "string") { + return JSON.parse(result) as T; + } else { + return async () => JSON.parse(await activeState.doc!.getDoc()) as T; + } + } if (activeState.doc) { - return JSON.parse(activeState.doc.getDoc()) as T; + return unpackResult(activeState.doc.getDoc()); } else { return async () => { await activeState.loadingPromise; - return JSON.parse(activeState.doc!.getDoc()) as T; + return unpackResult(activeState.doc!.getDoc()); } } } From 904d2675d7a17ca0603e03001064af1d8167df76 Mon Sep 17 00:00:00 2001 From: Alex Shaw Date: Mon, 24 Nov 2025 13:57:25 +0000 Subject: [PATCH 3/3] Finished prototype of IndexedDB-based persistent storage --- .../ProjectStorageProvider.tsx | 21 ++++++----- src/project-persistence/project-history-db.ts | 2 +- .../project-history-hooks.ts | 35 +++++-------------- src/project-persistence/project-list-db.ts | 5 +-- src/project-persistence/project-list-hooks.ts | 4 +-- src/project-persistence/project-store-idb.ts | 10 +++--- src/store-persistence.ts | 8 ++--- 7 files changed, 31 insertions(+), 54 deletions(-) diff --git a/src/project-persistence/ProjectStorageProvider.tsx b/src/project-persistence/ProjectStorageProvider.tsx index b62c8b4b0..39f87d093 100644 --- a/src/project-persistence/ProjectStorageProvider.tsx +++ b/src/project-persistence/ProjectStorageProvider.tsx @@ -1,6 +1,6 @@ // ProjectContext.tsx import React, { createContext, useCallback, useContext, useState } from "react"; -import { ProjectList } from "./project-list-db"; +import { ProjectData, ProjectList } from "./project-list-db"; import { DocAccessor } from "./project-list-hooks"; import { writeProject, readProject } from "./project-store-idb"; @@ -46,29 +46,28 @@ export function ProjectStorageProvider({ const projectAccessorInternal = projectAccessor as DocAccessorInternal; const setProjectAccessor = useCallback( (newProjectStore: DocAccessor) => { - if (projectAccessor) { + if (projectAccessorInternal) { projectAccessorInternal.destroy(); } setProjectAccessorImpl(newProjectStore); }, - [projectAccessor] + [projectAccessorInternal] ); - const openProject = async ( - projectId: string, - onChangeObserver: () => void - ) => { + const openProject = (projectId: string, onChangeObserver: () => void) => { const newProjectAccessor: DocAccessorInternal = { - setDoc: (doc: string) => { - writeProject(projectId, doc); + setDoc: async (doc: string) => { + await writeProject(projectId, doc); onChangeObserver(); // Fine here, because we only use it to trigger modified behaviour, but... // TODO: handle synchronisation from other DB changes }, - getDoc: () => readProject(projectId), + getDoc: async () => { + return (await readProject(projectId)).contents; + }, destroy: () => {}, projectId, }; - return newProjectAccessor; + return Promise.resolve(newProjectAccessor); }; return ( diff --git a/src/project-persistence/project-history-db.ts b/src/project-persistence/project-history-db.ts index 10764cf7b..5b8931a0f 100644 --- a/src/project-persistence/project-history-db.ts +++ b/src/project-persistence/project-history-db.ts @@ -3,7 +3,7 @@ export interface HistoryEntry { projectId: string; revisionId: string; parentId: string; - data: Uint8Array; + data: string; timestamp: number; } diff --git a/src/project-persistence/project-history-hooks.ts b/src/project-persistence/project-history-hooks.ts index 346e340fe..b32c22f76 100644 --- a/src/project-persistence/project-history-hooks.ts +++ b/src/project-persistence/project-history-hooks.ts @@ -3,8 +3,7 @@ import { ProjectStorageContext } from "./ProjectStorageProvider"; import { HistoryEntry, HistoryList, withHistoryDb } from "./project-history-db"; import { modifyProject, ProjectEntry, withProjectDb } from "./project-list-db"; import { makeUID } from "./utils"; -import * as Y from "yjs"; -import { ProjectStoreYjs } from "./project-store-yjs"; +import { readProject } from "./project-store-idb"; import { useProjectList } from "./project-list-hooks"; /** @@ -36,21 +35,15 @@ export const useProjectHistory = (): ProjectHistoryActions => { const { newStoredProject } = useProjectList(); const getUpdateAtRevision = useCallback(async (projectId: string, revision: string) => { - const deltas: HistoryEntry[] = []; - let parentRevision = revision; - do { - const delta = await withHistoryDb("readonly", async (revisions) => { + const update = await withHistoryDb("readonly", async (revisions) => { return new Promise((res, _rej) => { const query = revisions .index("projectRevision") - .get([projectId, parentRevision]); + .get([projectId, revision]); query.onsuccess = () => res(query.result as HistoryEntry); }); }); - parentRevision = delta.parentId; - deltas.unshift(delta); - } while (parentRevision); - return Y.mergeUpdatesV2(deltas.map((d) => d.data)); + return update; }, []); const getProjectInfo = (projectId: string) => @@ -68,24 +61,12 @@ export const useProjectHistory = (): ProjectHistoryActions => { projectName: `${projectInfo.projectName} revision`, parentRevision: forkId, }); - const updates = await getUpdateAtRevision(projectId, projectRevision); - // TODO: I broke history! - //Y.applyUpdateV2(ydoc, updates); + const update = await getUpdateAtRevision(projectId, projectRevision); + return doc.setDoc(update.data); }, [getUpdateAtRevision, newStoredProject]); const saveRevision = useCallback(async (projectInfo: ProjectEntry) => { - const projectStore = new ProjectStoreYjs(projectInfo.id, () => { }); - await projectStore.persist(); - let newUpdate: Uint8Array; - if (projectInfo.parentRevision) { - const previousUpdate = await getUpdateAtRevision( - projectInfo.id, - projectInfo.parentRevision - ); - newUpdate = Y.encodeStateAsUpdateV2(projectStore.ydoc, previousUpdate); - } else { - newUpdate = Y.encodeStateAsUpdateV2(projectStore.ydoc); - } + const newUpdate = ((await readProject(projectInfo.id)).contents); const newRevision = makeUID(); await withHistoryDb("readwrite", async (revisions) => { return new Promise((res, _rej) => { @@ -100,7 +81,7 @@ export const useProjectHistory = (): ProjectHistoryActions => { }); }); await modifyProject(projectInfo.id, { parentRevision: newRevision }); - }, [getUpdateAtRevision]); + }, []); const getHistory = useCallback(async (projectId: string) => withHistoryDb("readonly", async (store) => { diff --git a/src/project-persistence/project-list-db.ts b/src/project-persistence/project-list-db.ts index f5b66c4c0..05bba15f5 100644 --- a/src/project-persistence/project-list-db.ts +++ b/src/project-persistence/project-list-db.ts @@ -91,10 +91,7 @@ const onUpgradeNeeded = (openRequest: IDBOpenDBRequest) => (evt: IDBVersionChang }; } - let projectDataStore: IDBObjectStore; if (!db.objectStoreNames.contains(ProjectDbTable.ProjectData)) { - projectDataStore = db.createObjectStore(ProjectDbTable.ProjectData, { keyPath: "id" }); - } else { - projectDataStore = tx!.objectStore(ProjectDbTable.ProjectData); + db.createObjectStore(ProjectDbTable.ProjectData, { keyPath: "id" }); } }; diff --git a/src/project-persistence/project-list-hooks.ts b/src/project-persistence/project-list-hooks.ts index 01f49446e..6370af193 100644 --- a/src/project-persistence/project-list-hooks.ts +++ b/src/project-persistence/project-list-hooks.ts @@ -76,7 +76,7 @@ export const useProjectList = (): ProjectListActions => { .projectName, }; }, - [projectList, setProjectAccessor] + [projectList, setProjectAccessor, openProject] ); const newStoredProject: () => Promise = @@ -95,7 +95,7 @@ export const useProjectList = (): ProjectListActions => { ); setProjectAccessor(newProjectStore); return { doc: newProjectStore, id: newProjectId }; - }, [setProjectAccessor]); + }, [setProjectAccessor, openProject]); const deleteProject: (id: string) => Promise = useCallback( async (id) => { diff --git a/src/project-persistence/project-store-idb.ts b/src/project-persistence/project-store-idb.ts index 84964f075..337c1d8b2 100644 --- a/src/project-persistence/project-store-idb.ts +++ b/src/project-persistence/project-store-idb.ts @@ -1,6 +1,6 @@ -import { ProjectDbTable, withProjectDb } from "./project-list-db"; +import { ProjectData, ProjectDbTable, withProjectDb } from "./project-list-db"; -export const writeProject = (projectId: string, contents: string) => withProjectDb("readwrite", (projects) => +export const writeProject = (projectId: string, contents: any) => withProjectDb("readwrite", (projects) => new Promise((res, _rej) => { const query = projects.put({ id: projectId, contents }) @@ -9,8 +9,8 @@ export const writeProject = (projectId: string, contents: string) => withProject , ProjectDbTable.ProjectData); export const readProject = (projectId: string) => withProjectDb("readonly", (projects) => - new Promise((res, _rej) => { + new Promise((res, _rej) => { const query = projects.get(projectId) - query.onsuccess = () => res(query.result as string); + query.onsuccess = () => res(query.result as ProjectData); }) - , ProjectDbTable.ProjectData); \ No newline at end of file + , ProjectDbTable.ProjectData); diff --git a/src/store-persistence.ts b/src/store-persistence.ts index 33ea9b339..2614bf592 100644 --- a/src/store-persistence.ts +++ b/src/store-persistence.ts @@ -35,9 +35,9 @@ export const projectStorage = () => { const getItem = (_name: string) => { const unpackResult = (result: string | Promise) => { if (typeof result === "string") { - return JSON.parse(result) as T; + return JSON.parse(result) as StorageValue; } else { - return async () => JSON.parse(await activeState.doc!.getDoc()) as T; + return (async () => JSON.parse(await activeState.doc!.getDoc()) as StorageValue)(); } } if (activeState.doc) { @@ -52,11 +52,11 @@ export const projectStorage = () => { const setItem = (_name: string, value: StorageValue) => { if (activeState.doc) { - activeState.doc.setDoc(JSON.stringify(value)); + return activeState.doc.setDoc(JSON.stringify(value)); } else { return async () => { await activeState.loadingPromise; - activeState.doc!.setDoc(JSON.stringify(value)); + return activeState.doc!.setDoc(JSON.stringify(value)); } } }