diff --git a/package-lock.json b/package-lock.json index 1c4940098..195ff89cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "crelt": "^1.0.5", "dompurify": "^3.2.5", "file-saver": "^2.0.5", + "idb": "^8.0.3", "lunr": "^2.3.9", "lunr-languages": "^1.14.0", "lzma": "^2.3.2", @@ -68,6 +69,7 @@ "cross-env": "^7.0.3", "ejs": "^3.1.9", "eslint": "^10.9.1", + "fake-indexeddb": "^6.2.5", "jsdom": "^28.1.0", "playwright": "^1.58.2", "prettier": "2.3.2", @@ -7545,6 +7547,16 @@ "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", "license": "MIT" }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -8278,10 +8290,9 @@ } }, "node_modules/idb": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", - "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", - "dev": true, + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", "license": "ISC" }, "node_modules/ignore": { @@ -13132,6 +13143,13 @@ "workbox-core": "7.4.1" } }, + "node_modules/workbox-background-sync/node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "dev": true, + "license": "ISC" + }, "node_modules/workbox-broadcast-update": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.1.tgz", @@ -13289,6 +13307,13 @@ "workbox-core": "7.4.1" } }, + "node_modules/workbox-expiration/node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "dev": true, + "license": "ISC" + }, "node_modules/workbox-google-analytics": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.1.tgz", diff --git a/package.json b/package.json index f89d5f57c..85006632c 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "crelt": "^1.0.5", "dompurify": "^3.2.5", "file-saver": "^2.0.5", + "idb": "^8.0.3", "lunr": "^2.3.9", "lunr-languages": "^1.14.0", "lzma": "^2.3.2", @@ -72,6 +73,7 @@ "cross-env": "^7.0.3", "ejs": "^3.1.9", "eslint": "^10.9.1", + "fake-indexeddb": "^6.2.5", "jsdom": "^28.1.0", "playwright": "^1.58.2", "prettier": "2.3.2", diff --git a/src/RootLayout.tsx b/src/RootLayout.tsx index 918edb8d8..168e47aeb 100644 --- a/src/RootLayout.tsx +++ b/src/RootLayout.tsx @@ -6,6 +6,8 @@ import { ErrorBoundary, UnexpectedErrorPage } from "@microbit/ui-patterns"; import { useCallback } from "react"; import { Outlet } from "react-router"; +import StorageVersionErrorPage from "./fs/StorageVersionErrorPage"; +import { useStorageVersionError } from "./fs/storage-status"; import { useDeployment } from "./deployment"; import { useLogging } from "./logging/logging-hooks"; @@ -20,6 +22,7 @@ const RootLayout = () => { (error: unknown) => logging.error("Uncaught render error", error), [logging] ); + const storageVersionError = useStorageVersionError(); return ( { )} > - + {storageVersionError ? : } ); }; diff --git a/src/e2e/app-test-fixtures.ts b/src/e2e/app-test-fixtures.ts index 2d84dda53..ddf9c22d5 100644 --- a/src/e2e/app-test-fixtures.ts +++ b/src/e2e/app-test-fixtures.ts @@ -5,8 +5,17 @@ type MyFixtures = { app: App; }; -export const test = base.extend({ - app: async ({ page, context }, use) => { +type Options = { + /** Hide IndexedDB so the editor falls back to session storage. */ + noIndexedDB: boolean; + /** Open the editor before the test. Off for tests that embed it. */ + autoGoto: boolean; +}; + +export const test = base.extend({ + noIndexedDB: [false, { option: true }], + autoGoto: [true, { option: true }], + app: async ({ page, context, noIndexedDB, autoGoto }, use) => { const app = new App(page, context); await context.grantPermissions(["clipboard-read", "clipboard-write"]); await context.addCookies([ @@ -29,7 +38,18 @@ export const test = base.extend({ url: app.baseUrl, }, ]); - await app.goto(); + if (noIndexedDB) { + await context.addInitScript(() => { + // The getter lives on the prototype so an own property is needed. + Object.defineProperty(window, "indexedDB", { + value: undefined, + configurable: true, + }); + }); + } + if (autoGoto) { + await app.goto(); + } await use(app); }, }); diff --git a/src/e2e/app.ts b/src/e2e/app.ts index 363bd056b..acf2d5718 100644 --- a/src/e2e/app.ts +++ b/src/e2e/app.ts @@ -32,10 +32,12 @@ export interface BrowserDownload { // E2E_PORT points the suite at a server on another port. const baseUrl = `http://localhost:${process.env.E2E_PORT ?? "3000"}`; -interface UrlOptions { +export interface UrlOptions { flags?: Flag[]; fragment?: string; language?: string; + /** Iframe controller mode, as embedded by classroom. */ + controller?: boolean; } interface SaveOptions { @@ -280,7 +282,7 @@ export class App { } async goto(options: UrlOptions = {}) { - await this.page.goto(optionsToURL(options)); + await this.page.goto(editorUrl(options)); // Wait for the page to be loaded await this.editor.waitFor(); } @@ -495,19 +497,28 @@ export class App { await this.page.getByRole("button", { name: "Close" }).click(); } - async closeAndExpectBeforeUnloadDialogVisible( - visible: boolean - ): Promise { - if (visible) { - this.page.on("dialog", async (dialog) => { - expect(dialog.type() === "beforeunload").toEqual(visible); - - // Though https://playwright.dev/docs/api/class-page#page-event-dialog - // says that dialog.dismiss() is needed otherwise the page will freeze, - // in practice, it appears that the dialog is dismissed automatically. - }); - } + async closeWithoutBeforeUnloadPrompt(): Promise { + expect(await this.closeAndCollectDialogs()).not.toContain("beforeunload"); + } + + async closeAndExpectBeforeUnloadPrompt(): Promise { + expect(await this.closeAndCollectDialogs()).toContain("beforeunload"); + } + + /** + * Playwright accepts a beforeunload dialog itself if nobody listens, so + * listen to see it. Any dialog is handled before the page can close. + */ + private async closeAndCollectDialogs(): Promise { + const dialogs: string[] = []; + this.page.on("dialog", async (dialog) => { + dialogs.push(dialog.type()); + await dialog.accept(); + }); + const closed = this.page.waitForEvent("close"); await this.page.close({ runBeforeUnload: true }); + await closed; + return dialogs; } async expectDocumentationTopLevelHeading( @@ -823,7 +834,7 @@ const getAbsoluteFilePath = (filePathFromProjectRoot: string) => { return path.join(dir.replace("src/e2e", ""), filePathFromProjectRoot); }; -const optionsToURL = (options: UrlOptions): string => { +export const editorUrl = (options: UrlOptions = {}): string => { const flags = new Set([ "none", "noWelcome", @@ -836,6 +847,9 @@ const optionsToURL = (options: UrlOptions): string => { if (options.language) { params.push(["l", options.language]); } + if (options.controller) { + params.push(["controller", "1"]); + } return ( baseUrl + // We didn't use BASE_URL here as CRA seems to set it to "" before running jest. diff --git a/src/e2e/edits.test.ts b/src/e2e/edits.test.ts index 356dcf528..e73aea805 100644 --- a/src/e2e/edits.test.ts +++ b/src/e2e/edits.test.ts @@ -7,25 +7,29 @@ import { test } from "./app-test-fixtures.js"; test.describe("edits", () => { test("doesn't prompt on close if no edits made", async ({ app }) => { - await app.closeAndExpectBeforeUnloadDialogVisible(false); + await app.closeWithoutBeforeUnloadPrompt(); }); - test("prompts on close if file edited", async ({ app }) => { + test("doesn't prompt on close if file edited, as the project is saved", async ({ + app, + }) => { await app.typeInEditor("A change!"); await app.expectEditorContainText(/A change/); - await app.closeAndExpectBeforeUnloadDialogVisible(true); + await app.closeWithoutBeforeUnloadPrompt(); }); - test("prompts on close if project name edited", async ({ app }) => { + test("doesn't prompt on close if project name edited, as the project is saved", async ({ + app, + }) => { const name = "idiosyncratic ruminant"; await app.setProjectName(name); await app.expectProjectName(name); - await app.closeAndExpectBeforeUnloadDialogVisible(true); + await app.closeWithoutBeforeUnloadPrompt(); }); - test("retains text across a reload via session storage", async ({ app }) => { + test("retains text across a reload", async ({ app }) => { await app.typeInEditor("A change!"); await app.expectEditorContainText(/A change/); @@ -33,4 +37,15 @@ test.describe("edits", () => { await app.expectEditorContainText(/A change/); }); + + test("retains text across a reload straight after typing", async ({ + app, + }) => { + // Writes are coalesced for a few hundred milliseconds, so this relies on + // the pending ones being flushed as the page unloads. + await app.typeInEditor("A change!"); + await app.page.reload(); + + await app.expectEditorContainText(/A change/); + }); }); diff --git a/src/e2e/iframe.test.ts b/src/e2e/iframe.test.ts new file mode 100644 index 000000000..137aad086 --- /dev/null +++ b/src/e2e/iframe.test.ts @@ -0,0 +1,128 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { expect, FrameLocator, Page } from "@playwright/test"; +import { editorUrl } from "./app.js"; +import { test } from "./app-test-fixtures.js"; + +interface EditorMessage { + type: "pyeditor"; + action: string; + project?: { files: Record }; +} + +const hostCode = "display.scroll('from the host')"; + +/** + * The host page is served by intercepting a request for it and is defined + * here, in the tests, so it is never part of the app. + */ +const hostPage = (editorSrc: string) => ` +Iframe host harness + + +`; + +const hostUrl = new URL("e2e-iframe-host.html", editorUrl()).href; + +const openEmbeddedEditor = async (page: Page): Promise => { + await page.route(hostUrl, (route) => + route.fulfill({ + contentType: "text/html", + body: hostPage(editorUrl({ controller: true })), + }) + ); + await page.goto(hostUrl); + const frame = page.frameLocator("iframe[name='editor']"); + await frame.getByTestId("editor").waitFor(); + return frame; +}; + +const editorText = (frame: FrameLocator) => + frame.getByTestId("editor").getByRole("textbox"); + +// Set by the host harness page. Evaluate callbacks run in the page, so the +// cast has to be repeated inside each rather than shared. +const receivedActions = (page: Page) => + page.evaluate(() => + (window as unknown as { messages: EditorMessage[] }).messages.map( + (m) => m.action + ) + ); + +const lastSavedMain = (page: Page) => + page.evaluate(() => { + const { messages } = window as unknown as { messages: EditorMessage[] }; + const saves = messages.filter((m) => m.action === "workspacesave"); + const encoded = saves[saves.length - 1]?.project?.files["main.py"]; + return encoded === undefined ? undefined : atob(encoded); + }); + +/** + * Controller mode, as embedded by classroom: the host owns the project and + * there is no storage or project management in the editor. + */ +test.describe("iframe controller mode", () => { + test.use({ autoGoto: false }); + + test("loads the host's project and reports back edits", async ({ app }) => { + const frame = await openEmbeddedEditor(app.page); + + await expect(editorText(frame)).toContainText("from the host"); + expect(await receivedActions(app.page)).toEqual([ + "workspacesync", + "workspaceloaded", + ]); + + await editorText(frame).fill("display.scroll('edited')"); + await expect + .poll(() => lastSavedMain(app.page)) + .toEqual("display.scroll('edited')"); + }); + + test("replaces the project when the host imports one", async ({ app }) => { + const frame = await openEmbeddedEditor(app.page); + await expect(editorText(frame)).toContainText("from the host"); + + await app.page.evaluate(() => { + const editor = document.querySelector("iframe")!.contentWindow!; + editor.postMessage( + { + type: "pyeditor", + action: "importproject", + project: "display.scroll('imported')", + }, + "*" + ); + }); + + await expect(editorText(frame)).toContainText("imported"); + }); + + test("prompts on close if file edited, as the host may not have saved", async ({ + app, + }) => { + const frame = await openEmbeddedEditor(app.page); + await expect(editorText(frame)).toContainText("from the host"); + + await editorText(frame).fill("display.scroll('edited')"); + await expect(editorText(frame)).toContainText("edited"); + + await app.closeAndExpectBeforeUnloadPrompt(); + }); +}); diff --git a/src/e2e/storage-errors.test.ts b/src/e2e/storage-errors.test.ts new file mode 100644 index 000000000..148d2de2f --- /dev/null +++ b/src/e2e/storage-errors.test.ts @@ -0,0 +1,43 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { test } from "./app-test-fixtures.js"; + +/** + * Without IndexedDB the editor keeps the project in session storage, as it + * did before the projects database, so closing the tab loses it and the + * before-unload prompt is back. + */ +test.describe("storage fallback", () => { + test.use({ noIndexedDB: true }); + + test("doesn't prompt on close if no edits made", async ({ app }) => { + await app.closeWithoutBeforeUnloadPrompt(); + }); + + test("prompts on close if file edited", async ({ app }) => { + await app.typeInEditor("A change!"); + await app.expectEditorContainText(/A change/); + + await app.closeAndExpectBeforeUnloadPrompt(); + }); + + test("prompts on close if project name edited", async ({ app }) => { + const name = "idiosyncratic ruminant"; + await app.setProjectName(name); + await app.expectProjectName(name); + + await app.closeAndExpectBeforeUnloadPrompt(); + }); + + test("retains text across a reload via session storage", async ({ app }) => { + await app.typeInEditor("A change!"); + await app.expectEditorContainText(/A change/); + + await app.page.reload(); + + await app.expectEditorContainText(/A change/); + }); +}); diff --git a/src/environment.ts b/src/environment.ts index 92ad85ded..48c3efe6a 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -8,3 +8,10 @@ export const version = import.meta.env.VITE_VERSION || "local"; export type Stage = "local" | "REVIEW" | "STAGING" | "PRODUCTION"; export const stage = (import.meta.env.VITE_STAGE || "local") as Stage; + +/** + * Stages real users reach. Development affordances (like offering to clear + * an incompatible projects database) are for the others. + */ +export const isPublicFacingStage = (s: Stage = stage): boolean => + s === "STAGING" || s === "PRODUCTION"; diff --git a/src/fs/StorageVersionErrorPage.tsx b/src/fs/StorageVersionErrorPage.tsx new file mode 100644 index 000000000..a63750f3c --- /dev/null +++ b/src/fs/StorageVersionErrorPage.tsx @@ -0,0 +1,56 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Button, Text } from "@microbit/ui"; +import { ErrorPage } from "@microbit/ui-patterns"; +import { + getCurrentProjectId, + sessionStorageIfPossible, +} from "./current-project"; +import { databaseName } from "./projects-db"; + +const deleteDatabase = (name: string) => + new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(name); + request.onsuccess = () => resolve(); + request.onerror = () => + reject(request.error ?? new Error("deleteDatabase failed")); + }); + +/** + * Shown on non-public stages when the projects database was created by an + * incompatible build. Review builds share one database, so this is expected + * to happen there now and then; the fix is to start again. + * + * Deliberately untranslated: it never appears on a public deployment. + */ +const StorageVersionErrorPage = () => { + const handleClearAndReload = async () => { + try { + await deleteDatabase(databaseName()); + } catch { + // Best effort; the reload will show this page again if it failed. + } + const session = sessionStorageIfPossible(); + if (session && getCurrentProjectId(session)) { + session.removeItem("currentProjectId"); + } + window.location.reload(); + }; + return ( + + + The project storage format has changed in this pre-release version and + the old data is not supported. Clearing removes every project stored by + review builds in this browser. + + + + ); +}; + +export default StorageVersionErrorPage; diff --git a/src/fs/current-project.test.ts b/src/fs/current-project.test.ts new file mode 100644 index 000000000..48619ae2b --- /dev/null +++ b/src/fs/current-project.test.ts @@ -0,0 +1,143 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import "fake-indexeddb/auto"; +import { vi } from "vitest"; +import { MockLogging } from "../logging/mock"; +import { + getCurrentProjectId, + openCurrentProjectStorage, + setCurrentProjectId, +} from "./current-project"; +import { IndexedDBFSStorage } from "./indexeddb-storage"; +import { databaseName, ProjectsDatabase } from "./projects-db"; +import { SessionStorageFSStorage } from "./storage"; +import { renderHook } from "@testing-library/react"; +import { + resetStorageStatus, + useProjectsDatabaseActive, + useStorageVersionError, +} from "./storage-status"; + +const deleteDatabase = () => + new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(databaseName()); + request.onsuccess = () => resolve(); + request.onerror = () => + reject(request.error ?? new Error("deleteDatabase failed")); + }); + +const encode = (text: string) => new TextEncoder().encode(text); + +describe("openCurrentProjectStorage", () => { + let logging: MockLogging; + beforeEach(async () => { + sessionStorage.clear(); + await deleteDatabase(); + logging = new MockLogging(); + resetStorageStatus(); + }); + + it("creates a new project and makes it current when there is nothing", async () => { + const storage = await openCurrentProjectStorage(logging); + expect(storage).toBeInstanceOf(IndexedDBFSStorage); + expect( + renderHook(() => useProjectsDatabaseActive()).result.current + ).toEqual(true); + const id = getCurrentProjectId(sessionStorage); + expect(id).toBeDefined(); + + const db = await ProjectsDatabase.open(); + expect((await db.list()).map((p) => p.id)).toEqual([id]); + expect(await db.files(id!)).toEqual({}); + db.close(); + await (storage as IndexedDBFSStorage).dispose(); + }); + + it("reopens the tab's current project and marks it most recent", async () => { + const db = await ProjectsDatabase.open(); + await db.create({ id: "old", name: "Old", timestamp: 1 }, {}); + await db.create({ id: "cur", name: "Cur", timestamp: 2 }, {}); + await db.touch("old", 3); + db.close(); + setCurrentProjectId(sessionStorage, "cur"); + + const storage = await openCurrentProjectStorage(logging); + expect(await storage!.projectName()).toEqual("Cur"); + const reopened = await ProjectsDatabase.open(); + expect((await reopened.mostRecent())?.id).toEqual("cur"); + reopened.close(); + await (storage as IndexedDBFSStorage).dispose(); + }); + + it("migrates a project from session storage on first load", async () => { + const legacy = new SessionStorageFSStorage(sessionStorage); + await legacy.write("main.py", encode("# mine")); + await legacy.write("helper.py", encode("# helper")); + await legacy.setProjectName("My project"); + sessionStorage.setItem("unrelated", "kept"); + + const storage = await openCurrentProjectStorage(logging); + + expect(await storage!.projectName()).toEqual("My project"); + expect(await storage!.ls()).toEqual(["helper.py", "main.py"]); + expect(Array.from(await storage!.read("main.py"))).toEqual( + Array.from(encode("# mine")) + ); + expect(await legacy.ls()).toEqual([]); + expect(await legacy.projectName()).toBeUndefined(); + expect(sessionStorage.getItem("unrelated")).toEqual("kept"); + expect(getCurrentProjectId(sessionStorage)).toBeDefined(); + await (storage as IndexedDBFSStorage).dispose(); + }); + + it("falls back to session storage when the database cannot be opened", async () => { + vi.spyOn(ProjectsDatabase, "open").mockRejectedValueOnce( + new Error("blocked") + ); + const storage = await openCurrentProjectStorage(logging); + expect(storage).toBeInstanceOf(SessionStorageFSStorage); + expect(logging.errors[0].message).toMatch(/using session storage/); + expect( + renderHook(() => useProjectsDatabaseActive()).result.current + ).toEqual(false); + }); + + it("falls back to session storage for an incompatible database on public stages", async () => { + vi.spyOn(ProjectsDatabase, "open").mockRejectedValueOnce( + new DOMException("nope", "VersionError") + ); + const storage = await openCurrentProjectStorage(logging, true); + expect(storage).toBeInstanceOf(SessionStorageFSStorage); + }); + + it("reports an incompatible database for clearing on non-public stages", async () => { + vi.spyOn(ProjectsDatabase, "open").mockRejectedValueOnce( + new DOMException("nope", "VersionError") + ); + const storage = await openCurrentProjectStorage(logging, false); + expect(storage).toBeUndefined(); + expect(logging.errors).toEqual([]); + const { result } = renderHook(() => useStorageVersionError()); + expect((result.current as DOMException).name).toEqual("VersionError"); + }); + + it("falls back to session storage without IndexedDB", async () => { + const original = globalThis.indexedDB; + Object.defineProperty(globalThis, "indexedDB", { + value: undefined, + configurable: true, + }); + try { + const storage = await openCurrentProjectStorage(logging); + expect(storage).toBeInstanceOf(SessionStorageFSStorage); + } finally { + Object.defineProperty(globalThis, "indexedDB", { + value: original, + configurable: true, + }); + } + }); +}); diff --git a/src/fs/current-project.ts b/src/fs/current-project.ts new file mode 100644 index 000000000..df9c620e1 --- /dev/null +++ b/src/fs/current-project.ts @@ -0,0 +1,124 @@ +/** + * Which project the editor opens, and its storage. + * + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { isPublicFacingStage } from "../environment"; +import { Logging } from "../logging/logging"; +import { generateId } from "./fs-util"; +import { IndexedDBFSStorage } from "./indexeddb-storage"; +import { ProjectsDatabase } from "./projects-db"; +import { FSStorage, SessionStorageFSStorage } from "./storage"; +import { + reportProjectsDatabaseActive, + reportStorageVersionError, +} from "./storage-status"; + +/** + * The open project is per tab, as the whole project used to be. + */ +const currentProjectKey = "currentProjectId"; + +export const getCurrentProjectId = ( + session: Storage | undefined +): string | undefined => session?.getItem(currentProjectKey) ?? undefined; + +export const setCurrentProjectId = ( + session: Storage | undefined, + id: string +): void => session?.setItem(currentProjectKey, id); + +export const sessionStorageIfPossible = (): Storage | undefined => { + try { + return window.sessionStorage; + } catch { + // SecurityError in some embedding scenarios (issue 736) and no window in + // tests; either way there is nothing to read. + return undefined; + } +}; + +/** + * Opens the storage for the tab's current project. + * + * In order: the project the tab already has open; a project migrated from + * the session-storage file system that predates the projects database, so a reload + * after deploying this lands in the user's work; otherwise a new project. + * + * Without IndexedDB (unavailable, blocked, or an incompatible database) this + * falls back to session storage, which is what the editor used before. On + * non-public stages an incompatible database is reported for the UI to offer + * clearing it instead, since review builds share one database. + */ +export const openCurrentProjectStorage = async ( + logging: Logging, + publicFacing: boolean = isPublicFacingStage() +): Promise => { + if (typeof indexedDB === "undefined") { + return SessionStorageFSStorage.create(); + } + let db: ProjectsDatabase; + try { + db = await ProjectsDatabase.open(); + } catch (e) { + if (isVersionError(e) && !publicFacing) { + reportStorageVersionError(e); + return undefined; + } + logging.error("Projects database unavailable, using session storage", e); + return SessionStorageFSStorage.create(); + } + const session = sessionStorageIfPossible(); + const id = await chooseProject(db, session); + reportProjectsDatabaseActive(); + return new IndexedDBFSStorage(db, id, (e) => + logging.error("Failed to save project", e) + ); +}; + +const chooseProject = async ( + db: ProjectsDatabase, + session: Storage | undefined +): Promise => { + const current = getCurrentProjectId(session); + if (current && (await db.get(current))) { + await db.touch(current); + return current; + } + const legacy = session && new SessionStorageFSStorage(session); + if (legacy && (await legacy.ls()).length > 0) { + const id = await migrateLegacyProject(db, legacy); + setCurrentProjectId(session, id); + return id; + } + const id = generateId(); + await db.create({ id, name: undefined, timestamp: Date.now() }, {}); + setCurrentProjectId(session, id); + return id; +}; + +/** + * Moves the single session-storage project into the database. The session + * storage copy is removed so this happens once; the id then stands in for it. + */ +const migrateLegacyProject = async ( + db: ProjectsDatabase, + legacy: SessionStorageFSStorage +): Promise => { + const files: Record = {}; + for (const name of await legacy.ls()) { + files[name] = await legacy.read(name); + } + const id = generateId(); + await db.create( + { id, name: await legacy.projectName(), timestamp: Date.now() }, + files + ); + await legacy.removeAll(); + return id; +}; + +const isVersionError = (e: unknown): boolean => + e instanceof DOMException && e.name === "VersionError"; diff --git a/src/fs/fs.test.ts b/src/fs/fs.test.ts index baa8cc299..ccaf45e6a 100644 --- a/src/fs/fs.test.ts +++ b/src/fs/fs.test.ts @@ -20,6 +20,7 @@ import { } from "./fs"; import { DefaultHost } from "./host"; import { defaultInitialProject } from "./initial-project"; +import { InMemoryFSStorage } from "./storage"; const hexes = [ fs.readFileSync("src/micropython/microbit-micropython-v1.hex", { @@ -295,5 +296,94 @@ describe("fs - diff", () => { }); }); +describe("Filesystem switchStorage", () => { + const logging = new ConsoleLogging(); + const host = new DefaultHost(); + const encode = (text: string) => new TextEncoder().encode(text); + + const otherProject = async () => { + const storage = new InMemoryFSStorage("Other project"); + await storage.write(MAIN_FILE, encode("# other main")); + await storage.write("helper.py", encode("# helper")); + await storage.markDirty(); + return storage; + }; + + it("presents the new storage's files, name and dirty flag", async () => { + const ufs = new FileSystem(logging, host, fsMicroPythonSource); + await ufs.initialize(); + const events: Project[] = []; + ufs.addEventListener("project_updated", (e) => { + events.push(e.project); + }); + + const storage = await otherProject(); + await ufs.switchStorage(storage); + + expect(ufs.project.name).toEqual("Other project"); + expect(ufs.project.files.map((f) => f.name)).toEqual([ + MAIN_FILE, + "helper.py", + ]); + expect(await asString(ufs.read(MAIN_FILE))).toEqual("# other main"); + expect(ufs.dirty).toEqual(true); + expect(events).toHaveLength(1); + }); + + it("bumps versions so editors reload the same-named file", async () => { + const ufs = new FileSystem(logging, host, fsMicroPythonSource); + await ufs.initialize(); + const before = ufs.project.files.find((f) => f.name === MAIN_FILE)!; + + await ufs.switchStorage(await otherProject()); + + const after = ufs.project.files.find((f) => f.name === MAIN_FILE)!; + expect(after.version).toBeGreaterThan(before.version); + }); + + it("gives the project a new id", async () => { + const ufs = new FileSystem(logging, host, fsMicroPythonSource); + await ufs.initialize(); + const before = ufs.project.id; + await ufs.switchStorage(await otherProject()); + expect(ufs.project.id).not.toEqual(before); + }); + + it("refills the hex file system from the new storage", async () => { + const ufs = new FileSystem(logging, host, fsMicroPythonSource); + await ufs.initialize(); + await ufs.switchStorage(await otherProject()); + + const stats = await ufs.statistics(); + expect(stats.files).toEqual(2); + expect(await ufs.toHexForSave()).toContain(":"); + }); + + it("directs later writes and removes to the new storage only", async () => { + const ufs = new FileSystem(logging, host, fsMicroPythonSource); + await ufs.initialize(); + const storage = await otherProject(); + await ufs.switchStorage(storage); + + await ufs.write("new.py", "# new", VersionAction.INCREMENT); + await ufs.remove("helper.py"); + + expect(await storage.ls()).toEqual([MAIN_FILE, "new.py"]); + const original = await new DefaultHost().createStorage(logging).ls(); + expect(original).not.toContain("new.py"); + }); + + it("waits for an in-flight initialisation", async () => { + const ufs = new FileSystem(logging, host, fsMicroPythonSource); + const initializing = ufs.initialize(); + await ufs.switchStorage(await otherProject()); + await initializing; + + expect(ufs.project.name).toEqual("Other project"); + expect(await asString(ufs.read(MAIN_FILE))).toEqual("# other main"); + expect((await ufs.statistics()).files).toEqual(2); + }); +}); + const asString = async (f: Promise) => new TextDecoder().decode((await f).data); diff --git a/src/fs/fs.ts b/src/fs/fs.ts index c032246fb..bd4bdeb61 100644 --- a/src/fs/fs.ts +++ b/src/fs/fs.ts @@ -160,8 +160,9 @@ export const isNameLengthValid = (filename: string): boolean => /** * The MicroPython file system adapted for convienient use from the UI. * - * For now we store contents backed by session storage so they're only - * persistent over a browser refresh or Chrome tab restore. + * Contents are held in memory and mirrored to the host's persistent storage: + * the current project in the IndexedDB projects database, or session storage where + * that is unavailable. * * We version files in a way that's designed to make UI updates simple. * If a UI action updates a file (e.g. load from disk) then we bump its version. @@ -253,6 +254,29 @@ export class FileSystem extends TypedEventTarget { return this.fs!; } + /** + * Switch to a different backing storage, typically another project. + * + * The new storage is the record from here on: reads, writes and the hex + * file system all reflect it. Versions of files present in the new storage + * are bumped so editors showing a same-named file reload it. + */ + async switchStorage(storage: FSStorage): Promise { + if (this.initializing) { + await this.initializing; + } + this.storage = storage; + this._dirty = await storage.isDirty(); + this.project = { ...this.project, id: generateId() }; + if (this.fs) { + await this.initializeFsFromStorage(this.fs); + } + for (const name of await storage.ls()) { + this.incrementFileVersion(name); + } + return this.notify(); + } + /** * Update the project name. * diff --git a/src/fs/host.ts b/src/fs/host.ts index 37480617f..7ccdecfd9 100644 --- a/src/fs/host.ts +++ b/src/fs/host.ts @@ -12,12 +12,8 @@ import { projectFilesToBase64, } from "./initial-project"; import { parseMigrationFromUrl } from "./migration"; -import { - FSStorage, - InMemoryFSStorage, - SessionStorageFSStorage, - SplitStrategyStorage, -} from "./storage"; +import { openCurrentProjectStorage } from "./current-project"; +import { FSStorage, InMemoryFSStorage, SplitStrategyStorage } from "./storage"; const messages = { type: "pyeditor", @@ -42,7 +38,7 @@ export class DefaultHost implements Host { createStorage(logging: Logging): FSStorage { return new SplitStrategyStorage( new InMemoryFSStorage(undefined), - SessionStorageFSStorage.create(), + openCurrentProjectStorage(logging), logging ); } diff --git a/src/fs/indexeddb-storage.test.ts b/src/fs/indexeddb-storage.test.ts new file mode 100644 index 000000000..f9e21388c --- /dev/null +++ b/src/fs/indexeddb-storage.test.ts @@ -0,0 +1,109 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import "fake-indexeddb/auto"; +import { vi } from "vitest"; +import { IndexedDBFSStorage } from "./indexeddb-storage"; +import { ProjectsDatabase } from "./projects-db"; +import { commonStorageTests } from "./storage-tests"; + +let counter = 0; +const uniqueName = () => `test-${Date.now()}-${counter++}`; +const projectId = "p1"; +const contents = (files: Record) => + Object.fromEntries( + Object.entries(files).map(([name, data]) => [name, Array.from(data)]) + ); + +const openWithProject = async () => { + const db = await ProjectsDatabase.open(uniqueName()); + await db.create({ id: projectId, name: undefined, timestamp: 1 }, {}); + return db; +}; + +describe("IndexedDBFSStorage", () => { + let db: ProjectsDatabase; + let storage: IndexedDBFSStorage; + let errors: unknown[]; + beforeEach(async () => { + db = await openWithProject(); + errors = []; + storage = new IndexedDBFSStorage(db, projectId, (e) => errors.push(e), 20); + }); + afterEach(async () => { + await storage.dispose(); + }); + + commonStorageTests(() => storage); + + it("coalesces writes into one transaction after the delay", async () => { + const apply = vi.spyOn(db, "apply"); + await storage.write("main.py", new Uint8Array([1])); + await storage.write("main.py", new Uint8Array([2])); + await storage.write("other.py", new Uint8Array([3])); + await storage.setProjectName("Renamed"); + expect(apply).not.toHaveBeenCalled(); + + await new Promise((resolve) => setTimeout(resolve, 40)); + + expect(apply).toHaveBeenCalledTimes(1); + expect(contents(await db.files(projectId))).toEqual({ + "main.py": [2], + "other.py": [3], + }); + expect((await db.get(projectId))?.name).toEqual("Renamed"); + }); + + it("does not track the dirty flag", async () => { + const apply = vi.spyOn(db, "apply"); + await storage.markDirty(); + await storage.flush(); + expect(await storage.isDirty()).toEqual(false); + expect(apply).not.toHaveBeenCalled(); + }); + + it("a remove after a write in the same batch wins", async () => { + await storage.write("main.py", new Uint8Array([1])); + await storage.remove("main.py"); + await storage.flush(); + expect(contents(await db.files(projectId))).toEqual({}); + }); + + it("bumps the project timestamp when it flushes changes", async () => { + await storage.write("main.py", new Uint8Array([1])); + await storage.flush(); + expect((await db.get(projectId))?.timestamp).toBeGreaterThan(1); + }); + + it("flushes when the page is hidden", async () => { + await storage.write("main.py", new Uint8Array([1])); + Object.defineProperty(document, "visibilityState", { + value: "hidden", + configurable: true, + }); + document.dispatchEvent(new Event("visibilitychange")); + // The flush is asynchronous; wait less than the scheduled delay. + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(contents(await db.files(projectId))).toEqual({ "main.py": [1] }); + Object.defineProperty(document, "visibilityState", { + value: "visible", + configurable: true, + }); + }); + + it("reports a failed flush and drops those changes rather than throwing", async () => { + vi.spyOn(db, "apply").mockRejectedValueOnce( + new DOMException("full", "QuotaExceededError") + ); + await storage.write("main.py", new Uint8Array([1])); + await storage.flush(); + expect(errors).toHaveLength(1); + expect((errors[0] as DOMException).name).toEqual("QuotaExceededError"); + + await storage.write("other.py", new Uint8Array([2])); + await storage.flush(); + expect(contents(await db.files(projectId))).toEqual({ "other.py": [2] }); + }); +}); diff --git a/src/fs/indexeddb-storage.ts b/src/fs/indexeddb-storage.ts new file mode 100644 index 000000000..47d6b09a8 --- /dev/null +++ b/src/fs/indexeddb-storage.ts @@ -0,0 +1,159 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { ProjectChanges, ProjectsDatabase } from "./projects-db"; +import { FSStorage } from "./storage"; + +const defaultFlushDelayMs = 300; + +/** + * File system storage for one project in the IndexedDB projects database. + * + * Intended as the secondary of a SplitStrategyStorage, so reads are rare and + * writes arrive on every keystroke. Writes are coalesced per file and flushed + * as one transaction after a short delay, and when the page is hidden or + * unloading. Reads flush first so they always see the latest write. + * + * Failures never propagate: the in-memory primary still holds the content, + * so a failed flush is reported through onError and the changes are dropped + * rather than retried forever against, say, a full quota. + * + * The dirty flag is not stored: it exists to warn before work is lost, and + * a project in the database outlives the tab. + */ +export class IndexedDBFSStorage implements FSStorage { + private pendingWrites = new Map(); + private pendingMeta: NonNullable = {}; + private timer: ReturnType | undefined; + private flushing: Promise = Promise.resolve(); + private readonly handleHidden = () => { + if (document.visibilityState === "hidden") { + void this.flush(); + } + }; + + constructor( + private db: ProjectsDatabase, + private projectId: string, + private onError: (e: unknown) => void, + private flushDelayMs: number = defaultFlushDelayMs + ) { + if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", this.handleHidden); + window.addEventListener("pagehide", this.handleHidden); + } + } + + /** Flushes, stops listening and closes the database. */ + async dispose(): Promise { + if (typeof document !== "undefined") { + document.removeEventListener("visibilitychange", this.handleHidden); + window.removeEventListener("pagehide", this.handleHidden); + } + await this.flush(); + this.db.close(); + } + + async ls(): Promise { + await this.flush(); + return this.db.fileNames(this.projectId); + } + + async exists(filename: string): Promise { + await this.flush(); + return (await this.db.file(this.projectId, filename)) !== undefined; + } + + async read(filename: string): Promise { + await this.flush(); + const data = await this.db.file(this.projectId, filename); + if (data === undefined) { + throw new Error(`No such file ${filename}`); + } + return data; + } + + async write(name: string, content: Uint8Array): Promise { + this.pendingWrites.set(name, content); + this.schedule(); + } + + async remove(name: string): Promise { + this.pendingWrites.set(name, null); + this.schedule(); + } + + async clear(): Promise { + for (const name of await this.ls()) { + this.pendingWrites.set(name, null); + } + this.pendingMeta = { name: undefined }; + await this.flush(); + } + + async setProjectName(projectName: string | undefined): Promise { + this.pendingMeta.name = projectName; + this.schedule(); + } + + async projectName(): Promise { + await this.flush(); + return (await this.db.get(this.projectId))?.name; + } + + async markDirty(): Promise {} + + async clearDirty(): Promise {} + + async isDirty(): Promise { + return false; + } + + private schedule(): void { + if (this.timer === undefined) { + this.timer = setTimeout(() => void this.flush(), this.flushDelayMs); + } + } + + /** + * Writes everything pending in one transaction. Safe to call at any time; + * concurrent calls queue behind each other. + */ + flush(): Promise { + clearTimeout(this.timer); + this.timer = undefined; + this.flushing = this.flushing.then(() => this.flushPending()); + return this.flushing; + } + + private async flushPending(): Promise { + if (this.pendingWrites.size === 0 && isEmpty(this.pendingMeta)) { + return; + } + const writes: Record = {}; + const deletes: string[] = []; + for (const [name, data] of this.pendingWrites) { + if (data === null) { + deletes.push(name); + } else { + writes[name] = data; + } + } + const changes: ProjectChanges = { + meta: { ...this.pendingMeta, timestamp: Date.now() }, + writes, + deletes, + }; + this.pendingWrites = new Map(); + this.pendingMeta = {}; + try { + await this.db.apply(this.projectId, changes); + } catch (e) { + this.onError(e); + } + } +} + +const isEmpty = (o: object) => Object.keys(o).length === 0; diff --git a/src/fs/projects-db.test.ts b/src/fs/projects-db.test.ts new file mode 100644 index 000000000..8ee5d290d --- /dev/null +++ b/src/fs/projects-db.test.ts @@ -0,0 +1,128 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import "fake-indexeddb/auto"; +import { openDB } from "idb"; +import { databaseName, ProjectMeta, ProjectsDatabase } from "./projects-db"; + +let counter = 0; +const uniqueName = () => `test-${Date.now()}-${counter++}`; + +const meta = (id: string, timestamp: number, name = id): ProjectMeta => ({ + id, + name, + timestamp, +}); +const bytes = (...values: number[]) => new Uint8Array(values); +// Typed arrays read back through fake-indexeddb under jsdom belong to another +// realm, so compare contents rather than objects. +const contents = (files: Record) => + Object.fromEntries( + Object.entries(files).map(([name, data]) => [name, Array.from(data)]) + ); + +describe("databaseName", () => { + it("is namespaced by the base path so production and beta stay apart", () => { + expect(databaseName("local", "/")).toEqual("python-editor"); + expect(databaseName("PRODUCTION", "/v/3/")).toEqual("python-editor/v/3"); + expect(databaseName("STAGING", "/v/beta/")).toEqual("python-editor/v/beta"); + }); + + it("is shared by every review build", () => { + expect(databaseName("REVIEW", "/some-branch/")).toEqual( + "python-editor-review" + ); + expect(databaseName("REVIEW", "/another/")).toEqual("python-editor-review"); + }); +}); + +describe("ProjectsDatabase", () => { + let db: ProjectsDatabase; + beforeEach(async () => { + db = await ProjectsDatabase.open(uniqueName()); + }); + afterEach(() => db.close()); + + it("starts empty", async () => { + expect(await db.list()).toEqual([]); + expect(await db.mostRecent()).toBeUndefined(); + }); + + it("creates projects with files and lists them most recent first", async () => { + await db.create(meta("a", 1), { "main.py": bytes(1) }); + await db.create(meta("b", 2), { "main.py": bytes(2), "x.py": bytes(3) }); + expect((await db.list()).map((p) => p.id)).toEqual(["b", "a"]); + expect(await db.mostRecent()).toEqual(meta("b", 2)); + expect(contents(await db.files("b"))).toEqual({ + "main.py": [2], + "x.py": [3], + }); + expect(await db.fileNames("a")).toEqual(["main.py"]); + expect(Array.from((await db.file("a", "main.py"))!)).toEqual([1]); + expect(await db.file("a", "nope.py")).toBeUndefined(); + }); + + it("applies metadata, writes and deletes together", async () => { + await db.create(meta("a", 1), { "main.py": bytes(1), "old.py": bytes(9) }); + await db.apply("a", { + meta: { name: "renamed", timestamp: 5 }, + writes: { "main.py": bytes(2), "new.py": bytes(3) }, + deletes: ["old.py"], + }); + expect(await db.get("a")).toEqual({ + id: "a", + name: "renamed", + timestamp: 5, + }); + expect(contents(await db.files("a"))).toEqual({ + "main.py": [2], + "new.py": [3], + }); + }); + + it("refuses changes to a project that does not exist", async () => { + await expect(db.apply("missing", { meta: { name: "x" } })).rejects.toThrow( + /No such project missing/ + ); + }); + + it("touch makes a project the most recent", async () => { + await db.create(meta("a", 1), {}); + await db.create(meta("b", 2), {}); + await db.touch("a", 3); + expect((await db.mostRecent())?.id).toEqual("a"); + }); + + it("duplicates a project's files under new metadata", async () => { + await db.create(meta("a", 1), { "main.py": bytes(1) }); + await db.duplicate("a", meta("b", 2, "copy")); + expect(await db.get("b")).toEqual(meta("b", 2, "copy")); + expect(contents(await db.files("b"))).toEqual({ "main.py": [1] }); + }); + + it("deletes a project and its files", async () => { + await db.create(meta("a", 1), { "main.py": bytes(1), "x.py": bytes(2) }); + await db.create(meta("b", 2), { "main.py": bytes(3) }); + await db.delete("a"); + expect((await db.list()).map((p) => p.id)).toEqual(["b"]); + expect(contents(await db.files("a"))).toEqual({}); + expect(contents(await db.files("b"))).toEqual({ "main.py": [3] }); + }); + + it("rejects a database created by an incompatible version", async () => { + const name = uniqueName(); + // Same version number, different stores: what a future schema change + // that forgot to bump the version would look like from an old build. + const other = await openDB(name, 1, { + upgrade(db) { + db.createObjectStore("something-else"); + }, + }); + other.close(); + await expect(ProjectsDatabase.open(name)).rejects.toMatchObject({ + name: "VersionError", + }); + }); +}); diff --git a/src/fs/projects-db.ts b/src/fs/projects-db.ts new file mode 100644 index 000000000..9a0f033e1 --- /dev/null +++ b/src/fs/projects-db.ts @@ -0,0 +1,192 @@ +/** + * The projects database in IndexedDB. + * + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { DBSchema, IDBPDatabase, openDB } from "idb"; +import { baseUrl } from "../base"; +import { Stage, stage as currentStage } from "../environment"; + +/** + * Project metadata. Deliberately holds no file content so listing is cheap. + */ +export interface ProjectMeta { + id: string; + name: string | undefined; + /** Last modified or opened, for ordering by recency. */ + timestamp: number; +} + +/** + * A file belonging to a project, keyed by [projectId, name]: a file's name + * is its identity within a project. + */ +export interface FileRecord { + projectId: string; + name: string; + data: Uint8Array; +} + +/** + * A set of changes to one project, applied in a single transaction. + */ +export interface ProjectChanges { + meta?: Partial>; + writes?: Record; + deletes?: string[]; +} + +const PROJECTS = "projects"; +const FILES = "files"; +const stores = [PROJECTS, FILES] as const; + +interface Schema extends DBSchema { + [PROJECTS]: { + key: string; + value: ProjectMeta; + }; + [FILES]: { + key: [string, string]; + value: FileRecord; + indexes: { projectId: string }; + }; +} + +const DB_VERSION = 1; + +/** + * Deployments share an origin, so the database name includes the base path + * to keep production's and beta's libraries apart. Review builds all share + * one: they are internal, and a project made on one branch is useful on the + * next. When a schema change breaks it, StorageVersionErrorPage offers to + * clear it. + */ +export const databaseName = ( + stage: Stage = currentStage, + base: string = baseUrl +): string => { + if (stage === "REVIEW") { + return "python-editor-review"; + } + return base === "/" + ? "python-editor" + : `python-editor${base.replace(/\/$/, "")}`; +}; + +export class ProjectsDatabase { + private constructor(private db: IDBPDatabase) {} + + /** + * Opens the database, creating it if needed. + * + * @throws a VersionError DOMException if the database exists but lacks the + * expected stores, which means a newer version of the app created it. + */ + static async open(name: string = databaseName()): Promise { + const db = await openDB(name, DB_VERSION, { + upgrade(db) { + db.createObjectStore(PROJECTS, { keyPath: "id" }); + const files = db.createObjectStore(FILES, { + keyPath: ["projectId", "name"], + }); + files.createIndex("projectId", "projectId"); + }, + }); + for (const store of stores) { + if (!db.objectStoreNames.contains(store)) { + db.close(); + throw new DOMException( + `Database ${name} has no ${store} store; it was created by an incompatible version of the app`, + "VersionError" + ); + } + } + return new ProjectsDatabase(db); + } + + close(): void { + this.db.close(); + } + + /** All projects, most recent first. */ + async list(): Promise { + const all = await this.db.getAll(PROJECTS); + return all.sort((a, b) => b.timestamp - a.timestamp); + } + + async get(id: string): Promise { + return this.db.get(PROJECTS, id); + } + + async mostRecent(): Promise { + return (await this.list())[0]; + } + + async files(id: string): Promise> { + const records = await this.db.getAllFromIndex(FILES, "projectId", id); + return Object.fromEntries(records.map((r) => [r.name, r.data])); + } + + async fileNames(id: string): Promise { + const keys = await this.db.getAllKeysFromIndex(FILES, "projectId", id); + return keys.map(([, name]) => name); + } + + async file(id: string, name: string): Promise { + return (await this.db.get(FILES, [id, name]))?.data; + } + + /** Creates a project and its files atomically. */ + async create( + meta: ProjectMeta, + files: Record + ): Promise { + const tx = this.db.transaction(stores, "readwrite"); + await tx.objectStore(PROJECTS).put(meta); + const fileStore = tx.objectStore(FILES); + for (const [name, data] of Object.entries(files)) { + await fileStore.put({ projectId: meta.id, name, data }); + } + await tx.done; + } + + /** Applies a set of changes to a project in one transaction. */ + async apply(id: string, changes: ProjectChanges): Promise { + const tx = this.db.transaction(stores, "readwrite"); + const projects = tx.objectStore(PROJECTS); + const existing = await projects.get(id); + if (!existing) { + throw new Error(`No such project ${id}`); + } + await projects.put({ ...existing, ...changes.meta, id }); + const fileStore = tx.objectStore(FILES); + for (const [name, data] of Object.entries(changes.writes ?? {})) { + await fileStore.put({ projectId: id, name, data }); + } + for (const name of changes.deletes ?? []) { + await fileStore.delete([id, name]); + } + await tx.done; + } + + /** Marks a project as the most recent. */ + async touch(id: string, timestamp: number = Date.now()): Promise { + await this.apply(id, { meta: { timestamp } }); + } + + async duplicate(sourceId: string, meta: ProjectMeta): Promise { + await this.create(meta, await this.files(sourceId)); + } + + async delete(id: string): Promise { + const tx = this.db.transaction(stores, "readwrite"); + await tx.objectStore(PROJECTS).delete(id); + const fileStore = tx.objectStore(FILES); + for (const key of await fileStore.index("projectId").getAllKeys(id)) { + await fileStore.delete(key); + } + await tx.done; + } +} diff --git a/src/fs/storage-status.ts b/src/fs/storage-status.ts new file mode 100644 index 000000000..804a5bbf9 --- /dev/null +++ b/src/fs/storage-status.ts @@ -0,0 +1,50 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { useSyncExternalStore } from "react"; + +// The storage opens at module load, before React mounts, so the outcome is +// held here for the UI to read rather than passed through props. +let versionError: unknown; +let projectsDatabaseActive = false; +const listeners = new Set<() => void>(); + +const notify = () => listeners.forEach((listener) => listener()); + +/** + * Records that the projects database was created by an incompatible version + * of the app. Only used on non-public stages, where the fix is to clear it. + */ +export const reportStorageVersionError = (error: unknown): void => { + versionError = error; + notify(); +}; + +/** + * Records that the open project is in the projects database, which outlives + * the tab. Not reported for the session-storage fallback or in iframe mode, + * where closing the tab still loses the work. + */ +export const reportProjectsDatabaseActive = (): void => { + projectsDatabaseActive = true; + notify(); +}; + +const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); +}; + +export const useStorageVersionError = (): unknown => + useSyncExternalStore(subscribe, () => versionError); + +export const useProjectsDatabaseActive = (): boolean => + useSyncExternalStore(subscribe, () => projectsDatabaseActive); + +/** For tests. */ +export const resetStorageStatus = (): void => { + versionError = undefined; + projectsDatabaseActive = false; +}; diff --git a/src/fs/storage-tests.ts b/src/fs/storage-tests.ts new file mode 100644 index 000000000..99a8f3c98 --- /dev/null +++ b/src/fs/storage-tests.ts @@ -0,0 +1,71 @@ +/** + * Behaviour every FSStorage implementation must have. Not a test file itself; + * each implementation's test calls it. + * + * (c) 2021, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { FSStorage } from "./storage"; + +export const commonStorageTests = (storage: () => FSStorage) => { + it("is empty", async () => { + expect(await storage().ls()).toEqual([]); + }); + + it("stores project name", async () => { + await storage().setProjectName("foo"); + expect(await storage().projectName()).toEqual("foo"); + }); + + it("stores files", async () => { + await storage().write("test1.py", new Uint8Array([1])); + await storage().write("test2.py", new Uint8Array([2])); + + expect(await storage().ls()).toEqual(["test1.py", "test2.py"]); + expect(await storage().exists("test1.py")).toEqual(true); + expect(await storage().exists("testX.py")).toEqual(false); + expect(Array.from(await storage().read("test1.py"))).toEqual([1]); + expect(Array.from(await storage().read("test2.py"))).toEqual([2]); + }); + + it("throws trying to read a non-existent file", async () => { + await expect(() => storage().read("test1.py")).rejects.toThrow( + /No such file test1.py/ + ); + }); + + it("removes files", async () => { + await storage().write("test1.py", new Uint8Array([1])); + await storage().write("test2.py", new Uint8Array([2])); + + await storage().remove("test1.py"); + + expect(await storage().exists("test1.py")).toEqual(false); + expect(await storage().ls()).toEqual(["test2.py"]); + }); + + it("clears", async () => { + await storage().write("test1.py", new Uint8Array([1])); + await storage().write("test2.py", new Uint8Array([2])); + + await storage().clear(); + + expect(await storage().exists("test1.py")).toEqual(false); + expect(await storage().exists("test2.py")).toEqual(false); + expect(await storage().ls()).toEqual([]); + }); +}; + +/** + * For storage that lives no longer than the tab and so tracks the dirty flag. + */ +export const dirtyFlagTests = (storage: () => FSStorage) => { + it("stores dirty flag", async () => { + expect(await storage().isDirty()).toEqual(false); + await storage().markDirty(); + expect(await storage().isDirty()).toEqual(true); + await storage().clearDirty(); + expect(await storage().isDirty()).toEqual(false); + }); +}; diff --git a/src/fs/storage.test.ts b/src/fs/storage.test.ts index afbb52d1c..1d4c990cf 100644 --- a/src/fs/storage.test.ts +++ b/src/fs/storage.test.ts @@ -6,77 +6,21 @@ import { ConsoleLogging } from "../deployment/default/logging"; import { MockLogging } from "../logging/mock"; import { - FSStorage, InMemoryFSStorage, SessionStorageFSStorage, SplitStrategyStorage, } from "./storage"; +import { commonStorageTests, dirtyFlagTests } from "./storage-tests"; const projectName = "projectName"; -const commonStorageTests = (storage: FSStorage) => { - it("is empty", async () => { - expect(await storage.ls()).toEqual([]); - }); - - it("stores project name", async () => { - await storage.setProjectName("foo"); - expect(await storage.projectName()).toEqual("foo"); - }); - - it("stores dirty flag", async () => { - expect(await storage.isDirty()).toEqual(false); - await storage.markDirty(); - expect(await storage.isDirty()).toEqual(true); - await storage.clearDirty(); - expect(await storage.isDirty()).toEqual(false); - }); - - it("stores files", async () => { - await storage.write("test1.py", new Uint8Array([1])); - await storage.write("test2.py", new Uint8Array([2])); - - expect(await storage.ls()).toEqual(["test1.py", "test2.py"]); - expect(await storage.exists("test1.py")).toEqual(true); - expect(await storage.exists("testX.py")).toEqual(false); - expect(await storage.read("test1.py")).toEqual(new Uint8Array([1])); - expect(await storage.read("test2.py")).toEqual(new Uint8Array([2])); - }); - - it("throws trying to read a non-existent file", async () => { - await expect(() => storage.read("test1.py")).rejects.toThrow( - /No such file test1.py/ - ); - }); - - it("removes files", async () => { - await storage.write("test1.py", new Uint8Array([1])); - await storage.write("test2.py", new Uint8Array([2])); - - await storage.remove("test1.py"); - - expect(await storage.exists("test1.py")).toEqual(false); - expect(await storage.ls()).toEqual(["test2.py"]); - }); - - it("clears", async () => { - await storage.write("test1.py", new Uint8Array([1])); - await storage.write("test2.py", new Uint8Array([2])); - - await storage.clear(); - - expect(await storage.exists("test1.py")).toEqual(false); - expect(await storage.exists("test2.py")).toEqual(false); - expect(await storage.ls()).toEqual([]); - }); -}; - describe("SessionStorageFSStorage", () => { const storage = new SessionStorageFSStorage(sessionStorage); beforeEach(() => { sessionStorage.clear(); }); - commonStorageTests(storage); + commonStorageTests(() => storage); + dirtyFlagTests(() => storage); }); describe("InMemoryFSStorage", () => { @@ -84,7 +28,8 @@ describe("InMemoryFSStorage", () => { beforeEach(() => { storage.clear(); }); - commonStorageTests(storage); + commonStorageTests(() => storage); + dirtyFlagTests(() => storage); }); describe("SplitStrategyStorage", () => { @@ -98,7 +43,8 @@ describe("SplitStrategyStorage", () => { storage.clear(); sessionStorage.clear(); }); - commonStorageTests(storage); + commonStorageTests(() => storage); + dirtyFlagTests(() => storage); it("initializes from session storage", async () => { const memory = new InMemoryFSStorage(projectName); diff --git a/src/fs/storage.ts b/src/fs/storage.ts index 90ed81333..6ebc4a28c 100644 --- a/src/fs/storage.ts +++ b/src/fs/storage.ts @@ -23,8 +23,10 @@ export interface FSStorage { projectName(): Promise; clear(): Promise; /** - * We persist the dirty flag so that we know whether the user - * had previously made changes after a restore from storage. + * Whether the user has changed the project since the last hex save, used + * to warn before their work is lost. Storage that lives no longer than the + * tab persists it so the warning survives a reload; storage that outlives + * the tab has nothing to warn about and always reports false. */ markDirty(): Promise; clearDirty(): Promise; @@ -169,6 +171,18 @@ export class SessionStorageFSStorage implements FSStorage { this.storage.clear(); } + /** + * Removes the file system's keys and nothing else: session storage also + * holds session settings. + */ + async removeAll(): Promise { + for (const key of Object.keys(this.storage)) { + if (key.startsWith(fsFilesPrefix) || key.startsWith(fsMetadataPrefix)) { + this.storage.removeItem(key); + } + } + } + async markDirty(): Promise { this.storage.setItem(dirtyKey, "true"); } @@ -189,17 +203,25 @@ export class SessionStorageFSStorage implements FSStorage { */ export class SplitStrategyStorage implements FSStorage { private initialized: Promise; + private secondary: FSStorage | undefined; + /** + * @param secondary The persistent copy, or a promise of one for storage + * that takes time to open. Every operation waits for it. + */ constructor( private primary: FSStorage, - private secondary: FSStorage | undefined, + secondary: FSStorage | undefined | Promise, private log: Logging ) { - this.initialized = secondary - ? this.secondaryErrorHandle(async () => { - await initializeFromStorage(secondary, primary); - }) - : Promise.resolve(); + this.initialized = Promise.resolve(secondary).then((resolved) => { + this.secondary = resolved; + return resolved + ? this.secondaryErrorHandle(async () => { + await initializeFromStorage(resolved, primary); + }) + : undefined; + }); } async ls() { diff --git a/src/workbench/BeforeUnloadDirtyCheck.tsx b/src/workbench/BeforeUnloadDirtyCheck.tsx index 9901d82c6..f96e94a48 100644 --- a/src/workbench/BeforeUnloadDirtyCheck.tsx +++ b/src/workbench/BeforeUnloadDirtyCheck.tsx @@ -5,13 +5,20 @@ */ import { useEffect } from "react"; import { useFileSystem } from "../fs/fs-hooks"; +import { useProjectsDatabaseActive } from "../fs/storage-status"; /** - * Warns the user before closing a tab if they've made changes. + * Warns the user before closing a tab if they've made changes that would be + * lost. Nothing is lost when the project is in the projects database, so the + * warning only applies to the session-storage fallback and iframe mode. */ const BeforeUnloadDirtyCheck = () => { const fs = useFileSystem(); + const persisted = useProjectsDatabaseActive(); useEffect(() => { + if (persisted) { + return; + } const listener = (e: BeforeUnloadEvent) => { if (fs.dirty) { e.preventDefault(); @@ -24,7 +31,7 @@ const BeforeUnloadDirtyCheck = () => { return () => { window.removeEventListener("beforeunload", listener); }; - }, [fs]); + }, [fs, persisted]); return null; };