From dd1e67c756b3e1cb3b16e50f69a13b8a415ee90b Mon Sep 17 00:00:00 2001 From: Muntaser Syed Date: Fri, 14 Aug 2026 15:42:30 -0400 Subject: [PATCH 1/2] feat: add immutable corpus version creation --- CHANGELOG.md | 6 + README.md | 7 +- apps/web/README.md | 3 + apps/web/e2e/projects-live.spec.ts | 24 +- apps/web/e2e/projects.spec.ts | 84 ++++ apps/web/playwright.config.ts | 2 +- apps/web/playwright.live.config.ts | 2 +- apps/web/src/app/projects.css | 29 +- apps/web/src/app/projects/page.tsx | 14 +- .../src/components/project-workbench.test.tsx | 327 +++++++++++++- apps/web/src/components/project-workbench.tsx | 419 ++++++++++++++++-- apps/web/src/lib/platform.test.ts | 6 +- apps/web/src/lib/platform.ts | 1 + apps/web/src/lib/projects.test.ts | 55 +++ apps/web/src/lib/projects.ts | 39 ++ apps/web/src/playwright-config.test.ts | 16 +- contracts/openapi.json | 200 +++++++++ .../tenant-isolation-quotas-audit.md | 6 +- docs/product/15-minute-demo.md | 7 +- docs/product/capability-matrix.md | 11 +- docs/product/project-workspaces.md | 36 +- src/corpuskit/api/projects.py | 74 ++++ src/corpuskit/domain/platform.py | 10 + src/corpuskit/domain/workspaces.py | 20 + src/corpuskit/services/platform.py | 3 +- src/corpuskit/services/project_workspaces.py | 75 +++- src/corpuskit/services/projects.py | 71 ++- .../test_postgres_tenant_controls.py | 46 +- .../test_project_workspace_flows.py | 227 +++++++++- tests/unit/test_platform_domain.py | 13 + tests/unit/test_project_lifecycle.py | 24 + tests/unit/test_project_workspace_api.py | 140 ++++++ 32 files changed, 1906 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14d3f6e..53d4ee5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to CorpusKit will be documented here. The project follows ## [Unreleased] +### Added + +- Append-only corpus version creation from manual sentences or bounded UTF-8 TXT, CSV, and JSON + imports, with parent lineage, atomic quota accounting, audit evidence, API contracts, and an + accessible project-workbench flow. + ## [0.1.0-alpha.1] - 2026-08-12 ### Added diff --git a/README.md b/README.md index 607ff41..ef4de38 100644 --- a/README.md +++ b/README.md @@ -227,12 +227,11 @@ and session-encryption keys, and an explicit fixed internal API URL. Missing or auth/session infrastructure fails closed and never enables an in-memory fallback. The `/projects` workbench now demonstrates tenant-scoped project creation, bounded manual or -UTF-8 TXT/CSV/JSON corpus import, immutable version inspection, deterministic exports, and -owner/admin-confirmed project deletion. +UTF-8 TXT/CSV/JSON corpus import, append-only immutable corpus versions with explicit parent +lineage, version inspection, deterministic exports, and owner/admin-confirmed project deletion. The API router is integrated with the durable control plane through one application-owned database lifecycle; see [`docs/product/project-workspaces.md`](https://github.com/jemsbhai/corpuskit/blob/main/docs/product/project-workspaces.md). -Project/corpus update, individual corpus deletion, and creation of later corpus versions are not -presented as available. +Project/corpus metadata update and individual corpus deletion are not presented as available. The application also includes bounded G2P, PHOIBLE inventory, evaluation, distribution/text quality/error-rate/trajectory analysis, six-algorithm selection comparison, repository diff --git a/apps/web/README.md b/apps/web/README.md index 149e5d0..373a2ab 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -119,6 +119,9 @@ npm run test:e2e --workspace @corpuskit/web CORPUSKIT_LIVE_BASE_URL=http://127.0.0.1:3000 npm run test:e2e:live --workspace @corpuskit/web ``` +The explicit live runner executes both the end-to-end demo and the project workspace history +flow, including a real immutable successor append and historical-version export. + The scoped workbench configuration enforces at least 90% branch coverage per new state/transport module. Playwright runs every workbench route through Chromium, Firefox, and WebKit with axe, console/page/request failure checks, keyboard reachability, and a 320-pixel/200%-text layout diff --git a/apps/web/e2e/projects-live.spec.ts b/apps/web/e2e/projects-live.spec.ts index a6a2d79..b94f15b 100644 --- a/apps/web/e2e/projects-live.spec.ts +++ b/apps/web/e2e/projects-live.spec.ts @@ -1,11 +1,6 @@ import { expect, test } from "@playwright/test"; -test.skip( - !process.env.CORPUSKIT_LIVE_STACK, - "set CORPUSKIT_LIVE_STACK=1 for real API acceptance", -); - -test("real stack creates a tenant project and immutable manual corpus", async ({ +test("real stack creates a corpus, appends a version, and preserves history", async ({ page, }) => { const suffix = `${Date.now()}-${test.info().project.name}`; @@ -24,6 +19,23 @@ test("real stack creates a tenant project and immutable manual corpus", async ({ await expect( page.getByRole("table", { name: /Normalized sentences/ }), ).toContainText("你好世界"); + await page.getByLabel("Version eSpeak language").fill("en-gb"); + await page + .getByLabel("Version sentences") + .fill("Revised hello world\nA second immutable sentence"); + await page.getByRole("button", { name: "Create version" }).click(); + await expect(page.getByRole("button", { name: /Version 2/ })).toHaveAttribute( + "aria-pressed", + "true", + ); + await expect( + page.getByRole("table", { name: /Normalized sentences/ }), + ).toContainText("A second immutable sentence"); + + await page.getByRole("button", { name: /Version 1/ }).click(); + await expect( + page.getByRole("table", { name: /Normalized sentences/ }), + ).toContainText("你好世界"); const downloadPromise = page.waitForEvent("download"); await page.getByRole("link", { name: "JSON" }).click(); const download = await downloadPromise; diff --git a/apps/web/e2e/projects.spec.ts b/apps/web/e2e/projects.spec.ts index f2d37a8..0853081 100644 --- a/apps/web/e2e/projects.spec.ts +++ b/apps/web/e2e/projects.spec.ts @@ -30,6 +30,14 @@ const version = { corpusgen_version: "0.1.7", created_at: "2026-08-11T00:00:00Z", }; +const versionTwo = { + ...version, + id: "00000000-0000-4000-8000-000000000104", + parent_version_id: version.id, + version_number: 2, + language: "en-gb", + content_sha256: "b".repeat(64), +}; test("manual corpus workflow is keyboard-accessible and exports are downloadable", async ({ page, @@ -147,3 +155,79 @@ test("CSV import requires an explicit text column", async ({ page }) => { expect(uploadBody).toContain("seed.csv"); expect(uploadBody).toContain("text/csv"); }); + +test("manual corpus version creation refreshes and selects immutable history", async ({ + page, +}) => { + let submitted: unknown = null; + let appended = false; + await page.route("**/api/v1/**", async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + if (path === "/api/v1/auth/me" && request.method() === "GET") { + await route.fulfill({ json: principal }); + } else if (path === "/api/v1/projects" && request.method() === "GET") { + await route.fulfill({ json: [project] }); + } else if ( + path.endsWith(`/projects/${project.id}/corpora`) && + request.method() === "GET" + ) { + await route.fulfill({ json: [corpus] }); + } else if ( + path.endsWith(`/corpora/${corpus.id}/versions`) && + request.method() === "POST" + ) { + submitted = request.postDataJSON(); + appended = true; + await route.fulfill({ status: 201, json: versionTwo }); + } else if ( + path.endsWith(`/corpora/${corpus.id}/versions`) && + request.method() === "GET" + ) { + await route.fulfill({ + json: appended ? [version, versionTwo] : [version], + }); + } else if (path.endsWith(`/versions/${version.id}/sentences`)) { + await route.fulfill({ + json: [ + { ordinal: 0, original_text: "First", normalized_text: "First" }, + ], + }); + } else if (path.endsWith(`/versions/${versionTwo.id}/sentences`)) { + await route.fulfill({ + json: [ + { + ordinal: 0, + original_text: " Revised ", + normalized_text: "Revised", + }, + ], + }); + } else { + await route.fulfill({ status: 404 }); + } + }); + + await page.goto("/projects"); + await page.getByRole("button", { name: /Demo project/ }).click(); + await page.getByRole("button", { name: /Unicode seed/ }).click(); + await page.getByLabel("Version eSpeak language").fill("en-gb"); + await page.getByLabel("Version sentences").fill(" Revised "); + await page.getByRole("button", { name: "Create version" }).click(); + + await expect + .poll(() => submitted) + .toEqual({ + language: "en-gb", + sentences: [" Revised "], + }); + await expect(page.getByRole("button", { name: /Version 2/ })).toHaveAttribute( + "aria-pressed", + "true", + ); + await expect( + page.getByRole("table", { name: /Normalized sentences/ }), + ).toContainText("Revised"); + const accessibility = await new AxeBuilder({ page }).analyze(); + expect(accessibility.violations).toEqual([]); +}); diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 2b60590..7036e38 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -2,7 +2,7 @@ import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ testDir: "./e2e", - testIgnore: ["**/demo-live.spec.ts"], + testIgnore: ["**/demo-live.spec.ts", "**/projects-live.spec.ts"], fullyParallel: true, forbidOnly: Boolean(process.env.CI), // Flaky acceptance checks must fail visibly; CI does not conceal them with retries. diff --git a/apps/web/playwright.live.config.ts b/apps/web/playwright.live.config.ts index bfa7038..790c778 100644 --- a/apps/web/playwright.live.config.ts +++ b/apps/web/playwright.live.config.ts @@ -8,7 +8,7 @@ if (!baseURL) export default defineConfig({ testDir: "./e2e", - testMatch: "demo-live.spec.ts", + testMatch: ["demo-live.spec.ts", "projects-live.spec.ts"], fullyParallel: false, forbidOnly: true, retries: 0, diff --git a/apps/web/src/app/projects.css b/apps/web/src/app/projects.css index eb75f0d..8810df6 100644 --- a/apps/web/src/app/projects.css +++ b/apps/web/src/app/projects.css @@ -246,6 +246,32 @@ margin-top: 2rem; } +.version-layout { + margin-top: 2rem; + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(18rem, 0.65fr); + align-items: start; + gap: 1rem; +} + +.version-layout .version-browser { + min-width: 0; + margin-top: 0; +} + +.version-form > .field-help { + margin: -0.5rem 0 1rem; +} + +.version-permissions-note { + margin: 0; + padding: 1rem; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--paper); + color: var(--ink-soft); +} + .version-strip { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -333,7 +359,8 @@ @media (max-width: 900px) { .projects-intro, .project-layout, - .corpus-layout { + .corpus-layout, + .version-layout { grid-template-columns: 1fr; gap: 1.4rem; } diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx index 429c228..70d6d22 100644 --- a/apps/web/src/app/projects/page.tsx +++ b/apps/web/src/app/projects/page.tsx @@ -5,7 +5,7 @@ import { ProjectWorkbench } from "@/components/project-workbench"; export const metadata: Metadata = { title: "Project workspaces", description: - "Create projects and import, inspect, and export immutable speech corpora.", + "Create projects and build, inspect, and export immutable speech corpus histories.", }; export default function ProjectsPage() { @@ -21,14 +21,14 @@ export default function ProjectsPage() {

Build a reproducible corpus from manual sentences or a bounded UTF-8 - file, then inspect its immutable initial version and verified - exports. + file, add immutable successor versions, then inspect any snapshot + and its verified exports.

- This release creates and reads projects and version-1 corpora, and - owners/admins can request retention-governed project deletion with - exact confirmation. Project updates, individual corpus deletion, and - later corpus versions are not yet available. + Owners, admins, and editors can create corpora and later immutable + versions. Owners/admins can request retention-governed project + deletion with exact confirmation. Project updates and individual + corpus deletion are not yet available.

diff --git a/apps/web/src/components/project-workbench.test.tsx b/apps/web/src/components/project-workbench.test.tsx index c8b4dfd..beef11c 100644 --- a/apps/web/src/components/project-workbench.test.tsx +++ b/apps/web/src/components/project-workbench.test.tsx @@ -12,9 +12,11 @@ import { ProjectWorkbench } from "./project-workbench"; import { ProjectProvider } from "./project-context"; import { createManualCorpus, + createManualVersion, createProject, getCurrentPrincipal, importCorpus, + importCorpusVersion, listCorpora, listProjects, listSentences, @@ -27,9 +29,11 @@ vi.mock("@/lib/projects", async (importOriginal) => { return { ...original, createManualCorpus: vi.fn(), + createManualVersion: vi.fn(), createProject: vi.fn(), getCurrentPrincipal: vi.fn(), importCorpus: vi.fn(), + importCorpusVersion: vi.fn(), listCorpora: vi.fn(), listProjects: vi.fn(), listSentences: vi.fn(), @@ -192,11 +196,15 @@ describe("ProjectWorkbench", () => { it("creates a manual corpus then exposes digest, sentences, and all exports", async () => { const user = userEvent.setup(); + let corpusCreated = false; vi.mocked(listProjects).mockResolvedValue([project]); - vi.mocked(createManualCorpus).mockResolvedValue({ corpus, version }); - vi.mocked(listCorpora) - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([corpus]); + vi.mocked(createManualCorpus).mockImplementation(async () => { + corpusCreated = true; + return { corpus, version }; + }); + vi.mocked(listCorpora).mockImplementation(async () => + corpusCreated ? [corpus] : [], + ); vi.mocked(listVersions).mockResolvedValue([version]); vi.mocked(listSentences).mockResolvedValue([ { ordinal: 0, original_text: " Héllo ", normalized_text: "Héllo" }, @@ -322,9 +330,10 @@ describe("ProjectWorkbench", () => { it("browses multiple immutable versions and sanitizes read failures", async () => { const user = userEvent.setup(); let rejectVersionTwo = false; + const latestVersion = { ...versionTwo, language: "fr-fr" }; vi.mocked(listProjects).mockResolvedValue([project]); vi.mocked(listCorpora).mockResolvedValue([corpus]); - vi.mocked(listVersions).mockResolvedValue([version, versionTwo]); + vi.mocked(listVersions).mockResolvedValue([version, latestVersion]); vi.mocked(listSentences).mockImplementation( async (_projectId, _corpusId, versionId) => { if (versionId === versionTwo.id) { @@ -360,6 +369,9 @@ describe("ProjectWorkbench", () => { expect(await screen.findByRole("status")).toHaveTextContent( "Version 1 loaded", ); + expect(screen.getByLabelText("Version eSpeak language")).toHaveValue( + "fr-fr", + ); expect(screen.getAllByText("First")).toHaveLength(2); rejectVersionTwo = true; await user.click(screen.getByRole("button", { name: /Version 2/ })); @@ -369,6 +381,311 @@ describe("ProjectWorkbench", () => { expect(screen.getByRole("alert")).not.toHaveTextContent("database"); }); + it("creates a manual successor then refreshes and selects it", async () => { + const user = userEvent.setup(); + let created = false; + const createdVersion = { + ...versionTwo, + language: "fr-fr", + sentence_count: 2, + }; + vi.mocked(listProjects).mockResolvedValue([project]); + vi.mocked(listCorpora).mockResolvedValue([corpus]); + vi.mocked(listVersions).mockImplementation(async () => + created ? [version, createdVersion] : [version], + ); + vi.mocked(listSentences).mockImplementation( + async (_projectId, _corpusId, versionId) => + versionId === createdVersion.id + ? [ + { + ordinal: 0, + original_text: " Première ", + normalized_text: "Première", + }, + { + ordinal: 1, + original_text: "Deuxième", + normalized_text: "Deuxième", + }, + ] + : [ + { + ordinal: 0, + original_text: "First", + normalized_text: "First", + }, + ], + ); + vi.mocked(createManualVersion).mockImplementation(async () => { + created = true; + return createdVersion; + }); + renderWorkbench(); + + await user.click( + await screen.findByRole("button", { name: /Demo project/ }), + ); + await user.click( + await screen.findByRole("button", { name: /Unicode seed/ }), + ); + const language = await screen.findByLabelText("Version eSpeak language"); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Create version" }), + ).toBeEnabled(), + ); + await user.clear(language); + await user.type(language, "fr-fr"); + fireEvent.change(screen.getByLabelText(/Version sentences/), { + target: { value: " Première \n\nDeuxième" }, + }); + await user.click(screen.getByRole("button", { name: "Create version" })); + + await waitFor(() => + expect(createManualVersion).toHaveBeenCalledWith(project.id, corpus.id, { + language: "fr-fr", + sentences: [" Première ", "", "Deuxième"], + }), + ); + expect(await screen.findByRole("status")).toHaveTextContent( + "version 2 created and selected", + ); + const createdButton = screen.getByRole("button", { name: /Version 2/ }); + expect(createdButton).toHaveAttribute("aria-pressed", "true"); + expect(createdButton).toHaveFocus(); + expect(screen.getByText(createdVersion.content_sha256)).toBeVisible(); + expect(screen.getByLabelText(/Version sentences/)).toHaveValue(""); + expect(screen.getAllByText("Deuxième")).toHaveLength(2); + }); + + it("reports a committed version when its follow-up refresh fails", async () => { + const user = userEvent.setup(); + let created = false; + vi.mocked(listProjects).mockResolvedValue([project]); + vi.mocked(listCorpora).mockResolvedValue([corpus]); + vi.mocked(listVersions).mockImplementation(async () => { + if (created) throw new Error("replica unavailable"); + return [version]; + }); + vi.mocked(listSentences).mockResolvedValue([ + { ordinal: 0, original_text: "First", normalized_text: "First" }, + ]); + vi.mocked(createManualVersion).mockImplementation(async () => { + created = true; + return versionTwo; + }); + renderWorkbench(); + + await user.click( + await screen.findByRole("button", { name: /Demo project/ }), + ); + await user.click( + await screen.findByRole("button", { name: /Unicode seed/ }), + ); + await user.type( + await screen.findByLabelText(/Version sentences/), + "Second", + ); + await user.click(screen.getByRole("button", { name: "Create version" })); + + expect(await screen.findByRole("status")).toHaveTextContent( + "version 2 was created, but its sentences could not be refreshed", + ); + const recovered = screen.getByRole("button", { name: /Version 2/ }); + expect(recovered).toHaveAttribute("aria-pressed", "true"); + expect(recovered).toHaveFocus(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.getByLabelText(/Version sentences/)).toHaveValue(""); + }); + + it("does not apply an append response after the user selects another corpus", async () => { + const user = userEvent.setup(); + const otherCorpus = { + ...corpus, + id: "corpus-2", + name: "Other corpus", + }; + const otherVersion = { + ...version, + id: "other-version-1", + corpus_id: otherCorpus.id, + content_sha256: "c".repeat(64), + }; + let appendResolved = false; + let staleRefreshAttempted = false; + let resolveAppend!: (value: typeof versionTwo) => void; + vi.mocked(listProjects).mockResolvedValue([project]); + vi.mocked(listCorpora).mockResolvedValue([corpus, otherCorpus]); + vi.mocked(listVersions).mockImplementation(async (_projectId, corpusId) => { + if (appendResolved && corpusId === corpus.id) + staleRefreshAttempted = true; + return corpusId === otherCorpus.id ? [otherVersion] : [version]; + }); + vi.mocked(listSentences).mockImplementation( + async (_projectId, corpusId) => [ + { + ordinal: 0, + original_text: corpusId === otherCorpus.id ? "Other" : "First", + normalized_text: corpusId === otherCorpus.id ? "Other" : "First", + }, + ], + ); + vi.mocked(createManualVersion).mockReturnValue( + new Promise((resolve) => { + resolveAppend = resolve; + }), + ); + renderWorkbench(); + + await user.click( + await screen.findByRole("button", { name: /Demo project/ }), + ); + await user.click( + await screen.findByRole("button", { name: /Unicode seed/ }), + ); + await user.type( + await screen.findByLabelText(/Version sentences/), + "Second", + ); + await user.click(screen.getByRole("button", { name: "Create version" })); + await waitFor(() => expect(createManualVersion).toHaveBeenCalledTimes(1)); + await user.click(screen.getByRole("button", { name: /Other corpus/ })); + await waitFor(() => + expect( + screen.getByRole("button", { name: /Other corpus/ }), + ).toHaveAttribute("aria-pressed", "true"), + ); + expect(await screen.findByText(otherVersion.content_sha256)).toBeVisible(); + + appendResolved = true; + resolveAppend(versionTwo); + await Promise.resolve(); + await Promise.resolve(); + + expect(staleRefreshAttempted).toBe(false); + expect(screen.getByText(otherVersion.content_sha256)).toBeVisible(); + expect( + screen.queryByText(versionTwo.content_sha256), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /Other corpus/ }), + ).toHaveAttribute("aria-pressed", "true"); + }); + + it("lets an editor import a version file and clears it after selection", async () => { + const user = userEvent.setup(); + let created = false; + vi.mocked(getCurrentPrincipal).mockResolvedValue({ + subject: "editor-1", + organization_id: "00000000-0000-4000-8000-000000000001", + role: "editor", + display_name: "Editor", + }); + vi.mocked(listProjects).mockResolvedValue([project]); + vi.mocked(listCorpora).mockResolvedValue([corpus]); + vi.mocked(listVersions).mockImplementation(async () => + created ? [version, versionTwo] : [version], + ); + vi.mocked(listSentences).mockResolvedValue([]); + vi.mocked(importCorpusVersion).mockImplementation(async () => { + created = true; + return versionTwo; + }); + renderWorkbench(); + + await user.click( + await screen.findByRole("button", { name: /Demo project/ }), + ); + await user.click( + await screen.findByRole("button", { name: /Unicode seed/ }), + ); + expect( + await screen.findByRole("form", { + name: "Create the next immutable version", + }), + ).toBeVisible(); + expect( + screen.queryByRole("button", { name: "Delete project" }), + ).not.toBeInTheDocument(); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Create version" }), + ).toBeEnabled(), + ); + await user.click( + screen.getByRole("radio", { name: "Version file import" }), + ); + await user.selectOptions( + screen.getByLabelText("Version file format"), + "csv", + ); + await user.clear(screen.getByLabelText("Version CSV text column")); + await user.type( + screen.getByLabelText("Version CSV text column"), + "utterance", + ); + const file = new File(["utterance\nSecond\n"], "version.csv", { + type: "text/csv", + }); + const input = screen.getByLabelText( + "UTF-8 CSV version file", + ) as HTMLInputElement; + await user.upload(input, file); + expect(input.files?.[0]).toBe(file); + fireEvent.submit(input.closest("form")!); + + await waitFor(() => + expect(importCorpusVersion).toHaveBeenCalledWith(project.id, corpus.id, { + language: "en-us", + format: "csv", + textColumn: "utterance", + file, + }), + ); + expect(await screen.findByRole("status")).toHaveTextContent( + "version 2 created and selected", + ); + expect(input.value).toBe(""); + expect(input.files).toHaveLength(0); + }); + + it("keeps version creation read-only for viewers", async () => { + const user = userEvent.setup(); + vi.mocked(getCurrentPrincipal).mockResolvedValue({ + subject: "viewer-1", + organization_id: "00000000-0000-4000-8000-000000000001", + role: "viewer", + display_name: null, + }); + vi.mocked(listProjects).mockResolvedValue([project]); + vi.mocked(listCorpora).mockResolvedValue([corpus]); + vi.mocked(listVersions).mockResolvedValue([version]); + renderWorkbench(); + + await user.click( + await screen.findByRole("button", { name: /Demo project/ }), + ); + await user.click( + await screen.findByRole("button", { name: /Unicode seed/ }), + ); + expect( + await screen.findByText( + /Viewers can inspect and export immutable versions/, + ), + ).toBeVisible(); + expect( + screen.queryByRole("button", { name: "Create project" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Create corpus" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Create version" }), + ).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Version eSpeak language")).toBeNull(); + }); + it("handles plural project loading, missing versions, and form failures", async () => { const user = userEvent.setup(); const emptyProject = { diff --git a/apps/web/src/components/project-workbench.tsx b/apps/web/src/components/project-workbench.tsx index 63dae1d..4e9fc74 100644 --- a/apps/web/src/components/project-workbench.tsx +++ b/apps/web/src/components/project-workbench.tsx @@ -1,7 +1,7 @@ "use client"; import type { FormEvent, ReactNode } from "react"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useProjectContext } from "@/components/project-context"; import { @@ -10,9 +10,11 @@ import { MAX_SENTENCE_CHARACTERS, corpusExportHref, createManualCorpus, + createManualVersion, createProject, getCurrentPrincipal, importCorpus, + importCorpusVersion, listCorpora, listSentences, listVersions, @@ -26,6 +28,8 @@ import { type ProjectDeletion, } from "@/lib/projects"; +type WriteAccess = "checking" | "allowed" | "read-only" | "unavailable"; + export function ProjectWorkbench() { const projectContext = useProjectContext(); const projects = projectContext?.projects ?? []; @@ -39,10 +43,24 @@ export function ProjectWorkbench() { ); const [sentences, setSentences] = useState([]); const [canDeleteProjects, setCanDeleteProjects] = useState(false); + const [writeAccess, setWriteAccess] = useState("checking"); + const [versionFocusTarget, setVersionFocusTarget] = useState( + null, + ); const [pending, setPending] = useState(false); const [notice, setNotice] = useState(""); const [projectActionNotice, setProjectActionNotice] = useState(""); const [error, setError] = useState(null); + const selectionRevision = useRef(0); + const clearVersionFocusTarget = useCallback( + () => setVersionFocusTarget(null), + [], + ); + const beginVersionMutation = useCallback(() => selectionRevision.current, []); + + useEffect(() => { + selectionRevision.current += 1; + }, [selectedCorpus?.id, selectedProject?.id]); useEffect(() => { let active = true; @@ -53,9 +71,12 @@ export function ProjectWorkbench() { setCanDeleteProjects( principal.role === "owner" || principal.role === "admin", ); + setWriteAccess(principal.role === "viewer" ? "read-only" : "allowed"); }) .catch((caught: unknown) => { if (!active) return; + setCanDeleteProjects(false); + setWriteAccess("unavailable"); setError(workspaceError(caught)); setNotice(""); }); @@ -167,11 +188,15 @@ export function ProjectWorkbench() { function chooseProject(project: Project) { setProjectActionNotice(""); + setVersionFocusTarget(null); + selectionRevision.current += 1; projectContext?.selectProject(project.id); } async function chooseCorpus(corpus: Corpus) { if (!selectedProject) return; + setVersionFocusTarget(null); + selectionRevision.current += 1; setPending(true); setError(null); projectContext?.selectCorpusVersion(null); @@ -203,6 +228,7 @@ export function ProjectWorkbench() { async function chooseVersion(version: CorpusVersion) { if (!selectedProject || !selectedCorpus) return; + setVersionFocusTarget(null); setPending(true); setError(null); try { @@ -232,6 +258,53 @@ export function ProjectWorkbench() { if (created) await chooseCorpus(created); } + async function refreshVersions( + createdVersion: CorpusVersion, + expectedSelectionRevision: number, + ) { + if (!selectedProject || !selectedCorpus) return; + const project = selectedProject; + const corpus = selectedCorpus; + const selectionIsCurrent = () => + selectionRevision.current === expectedSelectionRevision; + if (!selectionIsCurrent()) return; + setError(null); + setNotice(""); + try { + const items = await listVersions(project.id, corpus.id); + if (!selectionIsCurrent()) return; + const created = items.find((item) => item.id === createdVersion.id); + if (!created) throw new Error("missing_created_version"); + const rows = await listSentences(project.id, corpus.id, created.id); + if (!selectionIsCurrent()) return; + setVersions(items); + setSelectedVersion(created); + setSentences(rows); + projectContext?.selectCorpusVersion({ corpus, version: created }); + setVersionFocusTarget(created.id); + setNotice( + `${corpus.name} version ${created.version_number} created and selected.`, + ); + } catch { + if (!selectionIsCurrent()) return; + setVersions((current) => + [ + ...current.filter((item) => item.id !== createdVersion.id), + createdVersion, + ].sort((left, right) => left.version_number - right.version_number), + ); + setSelectedVersion(createdVersion); + setSentences([]); + projectContext?.selectCorpusVersion({ corpus, version: createdVersion }); + setVersionFocusTarget(createdVersion.id); + setNotice( + `${corpus.name} version ${createdVersion.version_number} was created, but its sentences could not be refreshed. Reopen the corpus to retry.`, + ); + } + } + + const latestVersion = versions.at(-1) ?? null; + return (
- { - await projectContext?.refreshProjects(project.id); - }} - onError={setError} - /> + {writeAccess === "allowed" ? ( + { + await projectContext?.refreshProjects(project.id); + }} + onError={setError} + /> + ) : ( + + )}
{selectedProject && canDeleteProjects ? ( Corpora

- Manual entry and strict TXT, CSV, or JSON imports create version 1. - Corpus update and deletion are not available. + Manual entry and strict TXT, CSV, or JSON imports create immutable + corpora. Corpus update and deletion are not available.

{!selectedProject ? ( @@ -329,14 +409,21 @@ export function ProjectWorkbench() { selected={selectedCorpus} onSelect={chooseCorpus} /> - { - await refreshCorpora(name); - }} - onError={setError} - /> + {writeAccess === "allowed" ? ( + { + await refreshCorpora(name); + }} + onError={setError} + /> + ) : ( + + )} )} @@ -350,24 +437,51 @@ export function ProjectWorkbench() {

Versions & sentences

- Every digest identifies normalized text in deterministic sentence - order. + Create immutable successors, then inspect any digest in + deterministic sentence order.

{!selectedCorpus || !selectedProject ? ( - Select a corpus to inspect its immutable initial version. + Select a corpus to inspect and extend its immutable history. ) : ( - +
+ + {writeAccess === "allowed" ? ( + latestVersion ? ( + + ) : ( +

+ Load an existing version before creating its successor. +

+ ) + ) : ( + + )} +
)} @@ -780,6 +894,214 @@ function CorpusForm({ ); } +function VersionForm({ + projectId, + corpusId, + initialLanguage, + pending, + onMutationStarted, + onCreated, + onError, +}: { + projectId: string; + corpusId: string; + initialLanguage: string; + pending: boolean; + onMutationStarted: () => number; + onCreated: ( + version: CorpusVersion, + selectionRevision: number, + ) => Promise; + onError: (message: string | null) => void; +}) { + const [mode, setMode] = useState<"manual" | "file">("manual"); + const [languageOverride, setLanguageOverride] = useState(null); + const language = languageOverride ?? initialLanguage; + const [sentenceText, setSentenceText] = useState(""); + const [format, setFormat] = useState("txt"); + const [textColumn, setTextColumn] = useState("text"); + const [file, setFile] = useState(null); + const [submitting, setSubmitting] = useState(false); + const submissionLock = useRef(false); + const fileInput = useRef(null); + + async function submit(event: FormEvent) { + event.preventDefault(); + if (submissionLock.current || pending) return; + submissionLock.current = true; + setSubmitting(true); + onError(null); + const selectionRevision = onMutationStarted(); + try { + let created: CorpusVersion; + if (mode === "manual") { + const sentences = sentenceText.split(/\r?\n/u); + if ( + sentences.length > MAX_CORPUS_SENTENCES || + sentences.some((item) => item.length > MAX_SENTENCE_CHARACTERS) + ) { + throw new Error("client_limit"); + } + created = await createManualVersion(projectId, corpusId, { + language, + sentences, + }); + } else { + if (!file || file.size > MAX_CORPUS_FILE_BYTES) + throw new Error("client_file_limit"); + created = await importCorpusVersion(projectId, corpusId, { + language, + format, + textColumn: format === "csv" ? textColumn : null, + file, + }); + } + await onCreated(created, selectionRevision); + setLanguageOverride(null); + setSentenceText(""); + setFile(null); + if (fileInput.current) fileInput.current.value = ""; + } catch (caught) { + if (caught instanceof Error && caught.message === "client_limit") { + onError( + "Manual input is limited to 10,000 lines and 2,000 characters per sentence.", + ); + } else if ( + caught instanceof Error && + caught.message === "client_file_limit" + ) { + onError("Choose one UTF-8 file no larger than 10 MiB."); + } else { + onError(workspaceError(caught)); + } + } finally { + submissionLock.current = false; + setSubmitting(false); + } + } + + return ( +
void submit(event)} + > +

Create the next immutable version

+

+ The current corpus remains unchanged. CorpusKit records the latest + version as this snapshot's parent. +

+
+ Version input method + + +
+ + setLanguageOverride(event.target.value)} + /> + {mode === "manual" ? ( +
+ +