diff --git a/.github/workflows/extralit-frontend.yml b/.github/workflows/extralit-frontend.yml index 818359cd7..89e5187fc 100644 --- a/.github/workflows/extralit-frontend.yml +++ b/.github/workflows/extralit-frontend.yml @@ -41,6 +41,11 @@ jobs: run: | npm install + - name: Check generated v2 API types are current 🔒 + run: | + npm run gen:api:types + git diff --exit-code -- v2/infrastructure/api/generated + - name: Run lint 🧹 continue-on-error: true run: | diff --git a/.github/workflows/extralit-server.yml b/.github/workflows/extralit-server.yml index 1b5f736f6..f0a84681d 100644 --- a/.github/workflows/extralit-server.yml +++ b/.github/workflows/extralit-server.yml @@ -15,6 +15,7 @@ on: - releases/** paths: - "extralit-server/**" + - "extralit-frontend/v2/infrastructure/api/**" permissions: id-token: write @@ -96,6 +97,12 @@ jobs: - name: Install dependencies run: uv sync --dev --extra postgresql + - name: Check frontend v2 OpenAPI snapshot is current 🔒 + run: | + uv run python -m extralit_server.cli openapi-dump --output /tmp/openapi-v2.json + diff -u ../extralit-frontend/v2/infrastructure/api/openapi.json /tmp/openapi-v2.json \ + || { echo "::error::v2 OpenAPI drift — run 'npm run gen:api' in extralit-frontend and commit"; exit 1; } + - name: Run tests 📈 id: run-tests continue-on-error: true diff --git a/docs/superpowers/plans/2026-07-10-v2-frontend-vertical-slice.md b/docs/superpowers/plans/2026-07-10-v2-frontend-vertical-slice.md new file mode 100644 index 000000000..8c23bc7b2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-v2-frontend-vertical-slice.md @@ -0,0 +1,4805 @@ +# v2 Frontend Vertical Slice Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Spec:** `docs/superpowers/specs/2026-07-09-v2-frontend-vertical-slice-design.md` (parent: `2026-06-27-schema-centric-data-model-design.md`) + +**Goal:** Build the first v2 UI vertical slice — schemas → records → search → annotation — as an isolated `v2/` module in `extralit-frontend/`, consuming the shipped `/api/v2` surface, producing the reference-agnostic `ProjectionReviewForm`. + +**Architecture:** A new `v2/` DDD module mirrors `v1/` (entities → use-cases → repositories → Pinia storage → ts-injecty DI), registered by a `loadV2DependencyContainer` called after v1's in `plugins/3.di.ts`. API request/response shapes come from a checked-in OpenAPI snapshot + `openapi-typescript`-generated types; repositories stay hand-written on the shared axios instance. Four Argilla-inherited leaf widgets are *moved* (not copied) to `components/base/inputs/` and v1 wrappers re-pointed; the table editor is rebuilt lean on tabulator-tables. + +**Tech Stack:** Vue 3.5 / Nuxt 4 (SPA, `ssr:false`), Pinia, ts-injecty, axios (baseURL `/api`), openapi-typescript (new devDep, types only), tabulator-tables `^6.5.2`, @nuxtjs/i18n v10, Vitest + happy-dom + @vue/test-utils, Playwright (remote chromium over CDP). Server side: FastAPI + Typer CLI, uv, pytest. + +## Global Constraints + +- **v2→v1 import boundary:** `v2/**` may import from v1 ONLY: `v1/store/create.ts`, `v1/infrastructure/services/*`, and the extracted leaf inputs under `components/base/inputs/`. It must NOT import v1 entities (`Dataset`, `Record`, `Question`, `QuestionAnswer`) or v1 repositories. v1 never imports from `v2/`. + - **Documented exception (workspaces survive Phase 6):** v2 *pages/view-models* (not `v2/` module code) may read the current workspace via `useWorkspaces()` from `v1/infrastructure/storage/WorkspaceStorage.ts` and the `Workspace` entity. This avoids forking workspace fetching; record any other exception candidates in the spec ledger instead of importing. +- **Reuse-don't-fork:** leaves are extracted with `git mv` and v1 re-pointed; nothing is copy-pasted into `v2/`. +- **Name-keyed global registries:** ts-injecty DI and `useStoreFor` Pinia stores are keyed by **class name**; Nuxt component auto-import is **flat** (`pathPrefix: false`). Every v2 use-case/repository/storage-state class name and every new component name must be globally unique vs v1 (hence `V2RecordRepository`, `V2TableEditor`, etc.). +- **UI copy says "Schema"** — all new copy through i18n key families `schemas.*` and `review.*` in `translation/en.js` (fallbackLocale is `en`; other locales fall back). Never reuse `dataset`-keyed copy. +- **Routes:** `/schemas`, `/schemas/[id]`, `/schemas/[id]/settings`, `/references/[...reference]?workspace_id=`. No `/v2/` prefix. Always `encodeURIComponent(reference)` when building API URLs. +- **axios baseURL is `/api`** → repository paths start with `/v2/...`. The shared instance injects `Authorization: Bearer` and runs `AxiosErrorHandler` + `AxiosCache` interceptors — do not create a parallel HTTP client. +- **Server contract gotchas** (from spec §7/§10, verified against merged code): projection cells & `response.values` keyed by question **name**, suggestions by question **id**; `response.values` is double-wrapped `{question_name: {"value": …}}` on PUT and GET; `GET /records/{id}/responses` returns `null` with 200; two 422 body shapes (`detail: string` and `detail: [{loc,msg,type}]`); `total` is approximate; unknown filter columns → 5xx (UI must offer only known columns); `span` questions are rejected by the server (excluded from the slice). +- **Question value shapes the server validates** (v1 validator union): `text`→string, `label_selection`→string, `multi_label_selection`→string[], `rating`→number, `ranking`→`[{value, rank}]`, `table`→object with keys ⊆ bound columns. +- **Tooling:** Python via `uv` only (`uv run pytest`, `uv add`); frontend `npm run test` (vitest, colocated `*.test.ts`), `npm run lint`, `npm run format`. Frontend TS posture is `strict:false`; type-only imports must use inline `import { type X }` (Vite `isolatedModules`). +- **Commits:** conventional commits, one per task minimum, on a feature branch off `develop`. + +## Resolved open items (spec §9) + +1. **OpenAPI dump home:** new Typer subcommand `openapi-dump` in `extralit_server.cli` (Task 1) — testable, matches the existing CLI pattern. +2. **App home:** nav sibling — a third "Schemas" tab on the home page that navigates to `/schemas` (Task 6). Home swap deferred to Phase 6. +3. **i18n:** new `schemas.*` / `review.*` families in `translation/en.js` only (fallback covers de/es/ja). +4. **Rebuild-index affordance:** yes — button on `/schemas/[id]/settings` calling `POST /v2/schemas/{id}:rebuild-index`, showing the returned `{indexed: n}` (Task 8). +5. **Orphaned response values:** `ReferenceReview` assembly collects `orphanedValues` (response keys no question owns); the form surfaces them read-only and excludes them from emit payloads (Tasks 11, 14). + +**Spec deviation (recorded):** spec §7 sketches `` with a singular `draft` prop, but a reference spans multiple records each with its own draft. The plan puts each record's draft inside `ReviewRecord.draft` on the assembled entity; the form takes only `:review`. Emits keep the spec'd `(recordId, values)` signatures. + +## File structure + +``` +extralit-server/ + src/extralit_server/cli/openapi_dump.py # Task 1 (new) + src/extralit_server/cli/__init__.py # Task 1 (register command) + tests/unit/test_openapi_dump.py # Task 1 (new) + +extralit-frontend/ + v2/ + domain/entities/ + schema/Schema.ts, schema/ColumnMeta.ts, schema/SchemaVersion.ts # Task 3 + question/Question.ts # Task 3 + review/widget-mapping.ts (+ .test.ts) # Task 3 + record/V2Record.ts, record/RecordsPage.ts # Task 5 + search/SearchCriteria.ts (+ .test.ts) # Task 5 + review/response-values.ts (+ .test.ts) # Task 10 + review/ReferenceReview.ts, review/SuggestionHint.ts (+ tests) # Task 11 + review/widget-adapters.ts (+ .test.ts) # Task 14 + domain/usecases/ + get-schemas-use-case.ts, get-schema-settings-use-case.ts # Task 4 + get-schema-records-use-case.ts, search-records-use-case.ts, + rebuild-schema-index-use-case.ts # Task 5 + get-reference-review-use-case.ts (+ .test.ts) # Task 11 + submit-reference-review-use-case.ts, save-review-draft-use-case.ts, + discard-review-use-case.ts (+ tests) # Task 12 + infrastructure/ + api/openapi.json, api/generated/v2-api.ts # Task 2 (generated, committed) + repositories/SchemaRepository.ts (+ .test.ts) # Task 4 + repositories/V2RecordRepository.ts (+ .test.ts) # Task 5 + repositories/AnnotationRepository.ts, ProjectionRepository.ts, + repositories/apiErrors.ts (+ tests) # Task 10 + storage/SchemasStorage.ts # Task 4 + storage/ReferenceReviewsStorage.ts # Task 11 + di/di.ts, di/index.ts # Task 4 (grows each task) + components/base/inputs/ # Task 9 (moved leaves) + label-selection/{LabelSelection.component.vue, useLabelSelectionViewModel.ts} + rating/RatingMonoSelection.component.vue + ranking/{DndSelection.component.vue, ranking-adapter.js, ranking-fakes.js, ranking-adapter.test.js} + text-area/ContentEditableFeedbackTask.vue + components/v2/ + schemas/V2RecordsTable.vue # Task 7 + review/{ProjectionReviewForm.vue, ReviewRecordCard.vue, + ReviewCellInput.vue, ReviewProvenance.vue} (+ tests) # Task 14 + table/V2TableEditor.vue (+ .test.ts) # Task 13 + pages/schemas/index.vue + useSchemasViewModel.ts (+ test) # Task 6 + pages/schemas/[id]/index.vue + useSchemaRecordsViewModel.ts (+ test) # Task 7 + pages/schemas/[id]/settings.vue + useSchemaSettingsViewModel.ts (+ test)# Task 8 + pages/references/[...reference].vue + useReferenceReviewViewModel.ts # Task 15 + e2e/v2/ # Tasks 16–17 + fixtures.ts, seed/seed_v2_e2e.py, *.spec.ts + translation/en.js # Tasks 6, 8, 14 (add keys) + plugins/3.di.ts # Task 4 (call v2 loader) + playwright.config.ts, eslint.config.mjs, package.json # Tasks 2, 16 + +.github/workflows/extralit-frontend.yml # Task 2 (types drift gate) +.github/workflows/extralit-server.yml # Task 2 (snapshot drift gate) +``` + +--- + +### Task 1: Server `openapi-dump` CLI command + +**Files:** +- Create: `extralit-server/src/extralit_server/cli/openapi_dump.py` +- Modify: `extralit-server/src/extralit_server/cli/__init__.py` +- Test: `extralit-server/tests/unit/test_openapi_dump.py` + +**Interfaces:** +- Consumes: `extralit_server.api.v2.api_v2` (module-level FastAPI singleton; `api_v2.openapi()` returns the schema dict; paths are mount-relative, e.g. `/schemas`). +- Produces: CLI `python -m extralit_server.cli openapi-dump [--output PATH]` printing/writing deterministic JSON (`indent=2, sort_keys=True`, trailing newline). Task 2's `gen:api:snapshot` npm script and the server CI drift gate both invoke it. + +- [ ] **Step 1: Write the failing test** + +Create `extralit-server/tests/unit/test_openapi_dump.py`: + +```python +import json + +from typer.testing import CliRunner + +from extralit_server.cli import app + +runner = CliRunner() + + +def test_openapi_dump_writes_v2_schema(tmp_path): + output = tmp_path / "openapi.json" + + result = runner.invoke(app, ["openapi-dump", "--output", str(output)]) + + assert result.exit_code == 0 + schema = json.loads(output.read_text()) + assert schema["info"]["title"] == "Extralit v2" + assert "/schemas" in schema["paths"] + assert "/projection/references/{reference}" in schema["paths"] + + +def test_openapi_dump_is_deterministic(tmp_path): + first = tmp_path / "a.json" + second = tmp_path / "b.json" + + assert runner.invoke(app, ["openapi-dump", "--output", str(first)]).exit_code == 0 + assert runner.invoke(app, ["openapi-dump", "--output", str(second)]).exit_code == 0 + + assert first.read_bytes() == second.read_bytes() + + +def test_openapi_dump_prints_to_stdout_without_output(): + result = runner.invoke(app, ["openapi-dump"]) + + assert result.exit_code == 0 + assert json.loads(result.stdout)["info"]["title"] == "Extralit v2" +``` + +Note: if the `/projection/references/{reference}` path key assertion fails, print `schema["paths"].keys()` and use the exact key FastAPI emits for the `{reference:path}` route (it strips the `:path` converter to `{reference}`); fix the assertion, not the route. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd extralit-server && uv run pytest tests/unit/test_openapi_dump.py -v --disable-warnings` +Expected: FAIL — exit code 2 from Typer ("No such command 'openapi-dump'"). + +- [ ] **Step 3: Write the command** + +Create `extralit-server/src/extralit_server/cli/openapi_dump.py`: + +```python +import json +from pathlib import Path +from typing import Optional + +import typer + + +def openapi_dump( + output: Optional[Path] = typer.Option( + None, + "--output", + "-o", + help="Write the schema to this file instead of stdout", + ), +) -> None: + """Dump the /api/v2 OpenAPI schema as deterministic JSON (for frontend type generation).""" + # Imported lazily so `--help` stays fast and settings load only when the command runs. + from extralit_server.api.v2 import api_v2 + + text = json.dumps(api_v2.openapi(), indent=2, sort_keys=True) + "\n" + + if output is not None: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(text) + else: + typer.echo(text, nl=False) +``` + +Modify `extralit-server/src/extralit_server/cli/__init__.py` — add the import and registration (final file): + +```python +import typer + +from .database import app as database_app +from .index import app as index_app +from .openapi_dump import openapi_dump +from .search_engine import app as search_engine_app +from .start import start +from .worker import worker + +app = typer.Typer(help="Commands for Extralit server management", no_args_is_help=True) + +app.add_typer(database_app, name="database") +app.add_typer(index_app, name="index") +app.add_typer(search_engine_app, name="search-engine") +app.command(name="worker", help="Starts rq workers")(worker) +app.command(name="start", help="Starts the Extralit server")(start) +app.command(name="openapi-dump", help="Dump the /api/v2 OpenAPI schema as JSON")(openapi_dump) + +if __name__ == "__main__": + app() +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd extralit-server && uv run pytest tests/unit/test_openapi_dump.py -v --disable-warnings` +Expected: 3 PASSED. + +- [ ] **Step 5: Lint and commit** + +```bash +cd extralit-server && uv run ruff check src/extralit_server/cli tests/unit/test_openapi_dump.py +git add src/extralit_server/cli/openapi_dump.py src/extralit_server/cli/__init__.py tests/unit/test_openapi_dump.py +git commit -m "feat(server): add openapi-dump CLI command for v2 schema export" +``` + +--- + +### Task 2: Frontend typegen pipeline + drift gates + +**Files:** +- Create: `extralit-frontend/v2/infrastructure/api/openapi.json` (generated, committed) +- Create: `extralit-frontend/v2/infrastructure/api/generated/v2-api.ts` (generated, committed) +- Modify: `extralit-frontend/package.json` (devDep + scripts) +- Modify: `extralit-frontend/eslint.config.mjs` (ignore generated dir) +- Modify: `.github/workflows/extralit-frontend.yml` (types drift gate) +- Modify: `.github/workflows/extralit-server.yml` (snapshot drift gate) + +**Interfaces:** +- Consumes: Task 1's `openapi-dump` command. +- Produces: `import type { components } from "~/v2/infrastructure/api/generated/v2-api"` — all later repository tasks type DTOs as `components["schemas"]["SchemaRead"]`, `["SchemaVersionRead"]`, `["QuestionRead"]`, `["RecordRead"]`, `["Records"]`, `["RecordSearchQuery"]`, `["SuggestionRead"]`, `["ResponseRead"]`, `["ProjectionView"]`. npm scripts `gen:api`, `gen:api:snapshot`, `gen:api:types`. + +- [ ] **Step 1: Add openapi-typescript devDependency** + +```bash +cd extralit-frontend && npm install --save-dev openapi-typescript +``` + +- [ ] **Step 2: Add npm scripts** + +In `extralit-frontend/package.json` `"scripts"`, add (after `"test:coverage"`): + +```json +"gen:api": "npm run gen:api:snapshot && npm run gen:api:types", +"gen:api:snapshot": "uv run --project ../extralit-server python -m extralit_server.cli openapi-dump --output v2/infrastructure/api/openapi.json", +"gen:api:types": "openapi-typescript v2/infrastructure/api/openapi.json -o v2/infrastructure/api/generated/v2-api.ts && prettier --write v2/infrastructure/api/generated/v2-api.ts" +``` + +- [ ] **Step 3: Generate and inspect** + +Run: `cd extralit-frontend && npm run gen:api` +Expected: both files created. Open `v2/infrastructure/api/generated/v2-api.ts` and verify it exports `paths` and `components` with `components["schemas"]["SchemaRead"]` present. If `uv run --project` fails to resolve the server env, use `uv run --directory ../extralit-server python -m extralit_server.cli openapi-dump --output ../extralit-frontend/v2/infrastructure/api/openapi.json` in the script instead. + +- [ ] **Step 4: Ignore generated file in eslint** + +In `extralit-frontend/eslint.config.mjs`, first config object's `ignores` array, add after `"components/base/base-render-table/**",`: + +```js + "v2/infrastructure/api/generated/**", +``` + +- [ ] **Step 5: Frontend CI types drift gate** + +In `.github/workflows/extralit-frontend.yml`, insert between the "Install dependencies 📦" and "Run lint 🧹" steps: + +```yaml + - name: Check generated v2 API types are current 🔒 + run: | + npm run gen:api:types + git diff --exit-code -- v2/infrastructure/api/generated +``` + +(Only regenerates TS from the committed snapshot — no Python needed in this job. Snapshot↔server drift is gated server-side, next step.) + +- [ ] **Step 6: Server CI snapshot drift gate** + +In `.github/workflows/extralit-server.yml`, `build` job, insert between "Install dependencies" and "Run tests 📈": + +```yaml + - name: Check frontend v2 OpenAPI snapshot is current 🔒 + run: | + uv run python -m extralit_server.cli openapi-dump --output /tmp/openapi-v2.json + diff -u ../extralit-frontend/v2/infrastructure/api/openapi.json /tmp/openapi-v2.json \ + || { echo "::error::v2 OpenAPI drift — run 'npm run gen:api' in extralit-frontend and commit"; exit 1; } +``` + +Also add `"extralit-frontend/v2/infrastructure/api/**"` to that workflow's `on.push.paths` list so snapshot-only commits re-run the gate. + +- [ ] **Step 7: Verify determinism and commit** + +Run: `cd extralit-frontend && npm run gen:api && git status --porcelain -- v2/` — expected: no diff on second run (only untracked files from the first run staged below). + +```bash +git add extralit-frontend/package.json extralit-frontend/package-lock.json extralit-frontend/eslint.config.mjs \ + extralit-frontend/v2/infrastructure/api .github/workflows/extralit-frontend.yml .github/workflows/extralit-server.yml +git commit -m "feat(frontend): v2 OpenAPI snapshot + openapi-typescript typegen with CI drift gates" +``` + +--- + +### Task 3: v2 domain entities + widget mapping (pure domain, no infra) + +**Files:** +- Create: `extralit-frontend/v2/domain/entities/schema/Schema.ts` +- Create: `extralit-frontend/v2/domain/entities/schema/ColumnMeta.ts` +- Create: `extralit-frontend/v2/domain/entities/schema/SchemaVersion.ts` +- Create: `extralit-frontend/v2/domain/entities/question/Question.ts` +- Create: `extralit-frontend/v2/domain/entities/review/widget-mapping.ts` +- Test: `extralit-frontend/v2/domain/entities/review/widget-mapping.test.ts` +- Test: `extralit-frontend/v2/domain/entities/schema/SchemaVersion.test.ts` + +**Interfaces:** +- Consumes: nothing (leaf task). +- Produces (used by every later task): + - `class Schema { id, name, status, workspaceId, currentVersionId, settings, insertedAt, updatedAt }` + - `class ColumnMeta { name: string; dtype: string; nullable: boolean; review: ReviewOverlay | null }` + - `class SchemaVersion { id, schemaId, version: number, columnsCache: ColumnMeta[], reviewWidgets, insertedAt; findColumn(name): ColumnMeta | undefined }` + - `class Question { id, schemaId, name, title, description, type: QuestionType, columns: string[], settings, required; get options(): QuestionOption[]; get ratingValues(): number[] }` with `type QuestionType = "text" | "rating" | "label_selection" | "multi_label_selection" | "ranking" | "table"` and `interface QuestionOption { value: string; text: string; description: string | null }` + - `type CellEditor = "text" | "number" | "checkbox" | "date"`; `dtypeDefaultEditor(dtype): CellEditor`; `columnCellEditor(column: ColumnMeta): CellEditor`; `contextRenderer(column: ColumnMeta): CellEditor` + +- [ ] **Step 1: Write the failing tests** + +Create `extralit-frontend/v2/domain/entities/review/widget-mapping.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { ColumnMeta } from "../schema/ColumnMeta"; +import { columnCellEditor, contextRenderer, dtypeDefaultEditor } from "./widget-mapping"; + +describe("dtypeDefaultEditor", () => { + it.each([ + ["str", "text"], + ["int64", "number"], + ["int32", "number"], + ["float64", "number"], + ["bool", "checkbox"], + ["datetime64[ns]", "date"], + ["object", "text"], // unknown dtype falls back to text + ])("maps dtype %s to %s", (dtype, editor) => { + expect(dtypeDefaultEditor(dtype)).toBe(editor); + }); +}); + +describe("columnCellEditor (§6.2 precedence)", () => { + it("uses review.type when it is a known editor", () => { + const column = new ColumnMeta("score", "float64", true, { type: "text" }); + expect(columnCellEditor(column)).toBe("text"); + }); + + it("falls back to dtype default when review.type is unknown (forward-compatible overlay)", () => { + const column = new ColumnMeta("score", "float64", true, { type: "sparkline" }); + expect(columnCellEditor(column)).toBe("number"); + }); + + it("falls back to dtype default when review is null", () => { + const column = new ColumnMeta("done", "bool", false, null); + expect(columnCellEditor(column)).toBe("checkbox"); + }); +}); + +describe("contextRenderer (§6.3)", () => { + it("mirrors the same precedence for read-only context fields", () => { + expect(contextRenderer(new ColumnMeta("when", "datetime64[ns]", true, null))).toBe("date"); + expect(contextRenderer(new ColumnMeta("when", "datetime64[ns]", true, { type: "text" }))).toBe("text"); + }); +}); +``` + +Create `extralit-frontend/v2/domain/entities/schema/SchemaVersion.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { ColumnMeta } from "./ColumnMeta"; +import { SchemaVersion } from "./SchemaVersion"; + +describe("SchemaVersion", () => { + const version = new SchemaVersion( + "v-1", + "s-1", + 1, + [new ColumnMeta("title", "str", false, null)], + {}, + "2026-01-01T00:00:00" + ); + + it("finds a cached column by name", () => { + expect(version.findColumn("title")?.dtype).toBe("str"); + }); + + it("returns undefined for a column missing from this version's cache (old-version tolerance)", () => { + expect(version.findColumn("added_later")).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd extralit-frontend && npx vitest run v2/domain --reporter=verbose` +Expected: FAIL — cannot resolve `./widget-mapping` / `./SchemaVersion`. + +- [ ] **Step 3: Write the entities** + +Create `extralit-frontend/v2/domain/entities/schema/Schema.ts`: + +```ts +export class Schema { + constructor( + public readonly id: string, + public readonly name: string, + public readonly status: string, + public readonly workspaceId: string, + public readonly currentVersionId: string | null, + public readonly settings: Record, + public readonly insertedAt: string, + public readonly updatedAt: string + ) {} +} +``` + +Create `extralit-frontend/v2/domain/entities/schema/ColumnMeta.ts`: + +```ts +// The out-of-band per-column overlay (parent spec §13). Free-form JSONB: `type` is the +// only key the UI interprets; unknown types fall back to the dtype default. +export interface ReviewOverlay { + type?: string; + [key: string]: unknown; +} + +export class ColumnMeta { + constructor( + public readonly name: string, + public readonly dtype: string, + public readonly nullable: boolean, + public readonly review: ReviewOverlay | null + ) {} +} +``` + +Create `extralit-frontend/v2/domain/entities/schema/SchemaVersion.ts`: + +```ts +import { type ColumnMeta } from "./ColumnMeta"; + +export class SchemaVersion { + constructor( + public readonly id: string, + public readonly schemaId: string, + public readonly version: number, + public readonly columnsCache: ColumnMeta[], + public readonly reviewWidgets: Record>, + public readonly insertedAt: string + ) {} + + findColumn(name: string): ColumnMeta | undefined { + return this.columnsCache.find((column) => column.name === name); + } +} +``` + +Create `extralit-frontend/v2/domain/entities/question/Question.ts`: + +```ts +export type QuestionType = "text" | "rating" | "label_selection" | "multi_label_selection" | "ranking" | "table"; + +export interface QuestionOption { + value: string; + text: string; + description: string | null; +} + +export class Question { + constructor( + public readonly id: string, + public readonly schemaId: string, + public readonly name: string, + public readonly title: string, + public readonly description: string | null, + public readonly type: QuestionType, + public readonly columns: string[], + public readonly settings: Record, + public readonly required: boolean + ) {} + + get isLabelType(): boolean { + return this.type === "label_selection" || this.type === "multi_label_selection"; + } + + // label_selection / multi_label_selection / ranking settings.options: {value, text, description} + get options(): QuestionOption[] { + const options = (this.settings.options as QuestionOption[] | undefined) ?? []; + return options.map((o) => ({ value: o.value, text: o.text, description: o.description ?? null })); + } + + // rating settings.options: {value: int} + get ratingValues(): number[] { + const options = (this.settings.options as { value: number }[] | undefined) ?? []; + return options.map((o) => o.value); + } +} +``` + +Create `extralit-frontend/v2/domain/entities/review/widget-mapping.ts`: + +```ts +import { type ColumnMeta } from "../schema/ColumnMeta"; + +// Cell editors for table-question sub-columns and read-only context fields (spec §6.2/§6.3). +// Question-level widgets (§6.1) come straight from question.type — see ReviewCellInput. +export type CellEditor = "text" | "number" | "checkbox" | "date"; + +const KNOWN_EDITORS: CellEditor[] = ["text", "number", "checkbox", "date"]; + +export const dtypeDefaultEditor = (dtype: string): CellEditor => { + if (dtype.startsWith("int") || dtype.startsWith("float")) return "number"; + if (dtype === "bool") return "checkbox"; + if (dtype.startsWith("datetime")) return "date"; + return "text"; // str and anything unknown +}; + +export const columnCellEditor = (column: ColumnMeta): CellEditor => { + const hinted = column.review?.type; + if (hinted && (KNOWN_EDITORS as string[]).includes(hinted)) return hinted as CellEditor; + return dtypeDefaultEditor(column.dtype); +}; + +// Same precedence for non-question context fields; separate name so call sites read as §6.3. +export const contextRenderer = (column: ColumnMeta): CellEditor => columnCellEditor(column); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd extralit-frontend && npx vitest run v2/domain --reporter=verbose` +Expected: all PASS. + +- [ ] **Step 5: Lint and commit** + +```bash +cd extralit-frontend && npm run lint && npm run format +git add v2/domain +git commit -m "feat(v2-ui): schema/question domain entities + widget-selection mapping" +``` + +--- + +### Task 4: SchemaRepository + SchemasStorage + first use-cases + DI wiring + +**Files:** +- Create: `extralit-frontend/v2/infrastructure/repositories/SchemaRepository.ts` +- Create: `extralit-frontend/v2/infrastructure/storage/SchemasStorage.ts` +- Create: `extralit-frontend/v2/domain/usecases/get-schemas-use-case.ts` +- Create: `extralit-frontend/v2/domain/usecases/get-schema-settings-use-case.ts` +- Create: `extralit-frontend/v2/di/di.ts`, `extralit-frontend/v2/di/index.ts` +- Modify: `extralit-frontend/plugins/3.di.ts` +- Test: `extralit-frontend/v2/infrastructure/repositories/SchemaRepository.test.ts` +- Test: `extralit-frontend/v2/domain/usecases/get-schemas-use-case.test.ts` + +**Interfaces:** +- Consumes: Task 3 entities; Task 2 generated types; v1's `useAxiosExtension` (`v1/infrastructure/services/useAxiosExtension.ts`, allowed import) and `useStoreFor` (`v1/store/create.ts`, allowed import). +- Produces: + - `class SchemaRepository { constructor(axios: AxiosInstance); getSchemas(workspaceId: string): Promise; getSchema(schemaId: string): Promise; getVersions(schemaId: string): Promise; getQuestions(schemaId: string): Promise }` + - `useSchemas()` storage composable: `{ ...store, saveSchemas(schemas: Schema[]): void }` over state class `Schemas { schemas: Schema[] }` + - `class GetSchemasUseCase { constructor(SchemaRepository, useSchemas); execute(workspaceId: string): Promise }` + - `class GetSchemaSettingsUseCase { constructor(SchemaRepository); execute(schemaId: string): Promise<{ schema: Schema; versions: SchemaVersion[]; questions: Question[] }> }` + - `loadV2DependencyContainer(nuxtApp)` from `~/v2/di` — every later task appends registrations to `v2/di/di.ts`. + +- [ ] **Step 1: Write the failing tests** + +Create `extralit-frontend/v2/infrastructure/repositories/SchemaRepository.test.ts`: + +```ts +import { describe, expect, it, vi } from "vitest"; +import type { AxiosInstance } from "axios"; +import { SchemaRepository } from "./SchemaRepository"; + +const axiosMock = (getImpl: (url: string) => unknown) => + ({ get: vi.fn(async (url: string) => ({ data: getImpl(url) })) } as unknown as AxiosInstance); + +const BACKEND_SCHEMA = { + id: "s-1", + name: "sample_size", + status: "published", + current_version_id: "v-1", + settings: {}, + workspace_id: "w-1", + inserted_at: "2026-01-01T00:00:00", + updated_at: "2026-01-01T00:00:00", +}; + +describe("SchemaRepository", () => { + it("lists schemas for a workspace and maps to domain entities", async () => { + const axios = axiosMock(() => ({ items: [BACKEND_SCHEMA] })); + const repository = new SchemaRepository(axios); + + const schemas = await repository.getSchemas("w-1"); + + expect(axios.get).toHaveBeenCalledWith("/v2/schemas", { params: { workspace_id: "w-1" } }); + expect(schemas[0].workspaceId).toBe("w-1"); + expect(schemas[0].currentVersionId).toBe("v-1"); + }); + + it("maps versions including columns_cache to ColumnMeta", async () => { + const axios = axiosMock(() => [ + { + id: "v-1", + schema_id: "s-1", + version: 1, + object_key: "k", + object_version_id: null, + etag: "e", + checksum: "c", + parent_version_id: null, + columns_cache: [{ name: "title", dtype: "str", nullable: false, review: { type: "text" } }], + review_widgets: {}, + inserted_at: "2026-01-01T00:00:00", + }, + ]); + const repository = new SchemaRepository(axios); + + const versions = await repository.getVersions("s-1"); + + expect(axios.get).toHaveBeenCalledWith("/v2/schemas/s-1/versions"); + expect(versions[0].findColumn("title")?.review?.type).toBe("text"); + }); + + it("maps questions preserving type, columns and settings", async () => { + const axios = axiosMock(() => ({ + items: [ + { + id: "q-1", + schema_id: "s-1", + name: "label", + title: "Label", + description: null, + type: "label_selection", + columns: ["label"], + settings: { type: "label_selection", options: [{ value: "a", text: "A", description: null }] }, + required: true, + inserted_at: "2026-01-01T00:00:00", + updated_at: "2026-01-01T00:00:00", + }, + ], + })); + const repository = new SchemaRepository(axios); + + const questions = await repository.getQuestions("s-1"); + + expect(questions[0].type).toBe("label_selection"); + expect(questions[0].options).toEqual([{ value: "a", text: "A", description: null }]); + expect(questions[0].required).toBe(true); + }); +}); +``` + +Create `extralit-frontend/v2/domain/usecases/get-schemas-use-case.test.ts`: + +```ts +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; +import { GetSchemasUseCase } from "./get-schemas-use-case"; +import { Schema } from "../entities/schema/Schema"; +import { useSchemas } from "~/v2/infrastructure/storage/SchemasStorage"; + +const SCHEMA = new Schema("s-1", "sample_size", "published", "w-1", "v-1", {}, "2026-01-01", "2026-01-01"); + +describe("GetSchemasUseCase", () => { + beforeEach(() => setActivePinia(createPinia())); + + it("fetches schemas and saves them to storage", async () => { + const repository = { getSchemas: vi.fn(async () => [SCHEMA]) }; + const useCase = new GetSchemasUseCase(repository as never, useSchemas); + + const result = await useCase.execute("w-1"); + + expect(repository.getSchemas).toHaveBeenCalledWith("w-1"); + expect(result).toEqual([SCHEMA]); + expect(useSchemas().get().schemas).toEqual([SCHEMA]); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd extralit-frontend && npx vitest run v2 --reporter=verbose` +Expected: FAIL — modules not found. + +- [ ] **Step 3: Write repository, storage, use-cases, DI** + +Create `extralit-frontend/v2/infrastructure/repositories/SchemaRepository.ts`: + +```ts +import type { AxiosInstance } from "axios"; +import type { components } from "../api/generated/v2-api"; +import { Schema } from "~/v2/domain/entities/schema/Schema"; +import { ColumnMeta, type ReviewOverlay } from "~/v2/domain/entities/schema/ColumnMeta"; +import { SchemaVersion } from "~/v2/domain/entities/schema/SchemaVersion"; +import { Question, type QuestionType } from "~/v2/domain/entities/question/Question"; + +type BackendSchema = components["schemas"]["SchemaRead"]; +type BackendSchemas = components["schemas"]["Schemas"]; +type BackendVersion = components["schemas"]["SchemaVersionRead"]; +type BackendQuestions = components["schemas"]["Questions"]; +type BackendQuestion = components["schemas"]["QuestionRead"]; + +const toSchema = (backend: BackendSchema): Schema => + new Schema( + backend.id, + backend.name, + backend.status, + backend.workspace_id, + backend.current_version_id ?? null, + (backend.settings ?? {}) as Record, + backend.inserted_at, + backend.updated_at + ); + +const toVersion = (backend: BackendVersion): SchemaVersion => + new SchemaVersion( + backend.id, + backend.schema_id, + backend.version, + ((backend.columns_cache ?? []) as { name: string; dtype: string; nullable: boolean; review?: ReviewOverlay | null }[]).map( + (c) => new ColumnMeta(c.name, c.dtype, c.nullable, c.review ?? null) + ), + (backend.review_widgets ?? {}) as Record>, + backend.inserted_at + ); + +const toQuestion = (backend: BackendQuestion): Question => + new Question( + backend.id, + backend.schema_id, + backend.name, + backend.title, + backend.description ?? null, + backend.type as QuestionType, + backend.columns, + (backend.settings ?? {}) as Record, + backend.required + ); + +export class SchemaRepository { + constructor(private readonly axios: AxiosInstance) {} + + async getSchemas(workspaceId: string): Promise { + const { data } = await this.axios.get("/v2/schemas", { params: { workspace_id: workspaceId } }); + return data.items.map(toSchema); + } + + async getSchema(schemaId: string): Promise { + const { data } = await this.axios.get(`/v2/schemas/${schemaId}`); + return toSchema(data); + } + + async getVersions(schemaId: string): Promise { + const { data } = await this.axios.get(`/v2/schemas/${schemaId}/versions`); + return data.map(toVersion); + } + + async getQuestions(schemaId: string): Promise { + const { data } = await this.axios.get(`/v2/schemas/${schemaId}/questions`); + return data.items.map(toQuestion); + } +} +``` + +Note: if `npx nuxi typecheck` reports the generated `components["schemas"]` keys differ (e.g. FastAPI emits `Schemas` as `Schemas-Output`), read the actual key names from `v2/infrastructure/api/generated/v2-api.ts` and adjust the four `type Backend*` aliases — never hand-write the shapes. + +Create `extralit-frontend/v2/infrastructure/storage/SchemasStorage.ts`: + +```ts +import { useStoreFor } from "@/v1/store/create"; +import { Schema } from "~/v2/domain/entities/schema/Schema"; + +// Class name is the Pinia store key — must stay unique vs every v1 useStoreFor class. +class Schemas { + constructor(public readonly schemas: Schema[] = []) {} +} + +interface ISchemasStorage { + saveSchemas(schemas: Schema[]): void; +} + +const useStoreForSchemas = useStoreFor(Schemas); + +export const useSchemas = () => { + const store = useStoreForSchemas(); + + const saveSchemas = (schemas: Schema[]) => { + store.save(new Schemas(schemas)); + }; + + return { ...store, saveSchemas }; +}; +``` + +Create `extralit-frontend/v2/domain/usecases/get-schemas-use-case.ts`: + +```ts +import { Schema } from "../entities/schema/Schema"; +import { SchemaRepository } from "~/v2/infrastructure/repositories/SchemaRepository"; +import { type useSchemas } from "~/v2/infrastructure/storage/SchemasStorage"; + +export class GetSchemasUseCase { + constructor( + private readonly schemaRepository: SchemaRepository, + private readonly schemasStorage: typeof useSchemas + ) {} + + async execute(workspaceId: string): Promise { + const schemas = await this.schemaRepository.getSchemas(workspaceId); + this.schemasStorage().saveSchemas(schemas); + return schemas; + } +} +``` + +Create `extralit-frontend/v2/domain/usecases/get-schema-settings-use-case.ts`: + +```ts +import { Schema } from "../entities/schema/Schema"; +import { SchemaVersion } from "../entities/schema/SchemaVersion"; +import { Question } from "../entities/question/Question"; +import { SchemaRepository } from "~/v2/infrastructure/repositories/SchemaRepository"; + +export interface SchemaSettings { + schema: Schema; + versions: SchemaVersion[]; + questions: Question[]; +} + +export class GetSchemaSettingsUseCase { + constructor(private readonly schemaRepository: SchemaRepository) {} + + async execute(schemaId: string): Promise { + const [schema, versions, questions] = await Promise.all([ + this.schemaRepository.getSchema(schemaId), + this.schemaRepository.getVersions(schemaId), + this.schemaRepository.getQuestions(schemaId), + ]); + return { schema, versions, questions }; + } +} +``` + +Create `extralit-frontend/v2/di/di.ts`: + +```ts +import type { AxiosInstance } from "axios"; +import Container, { register } from "ts-injecty"; + +import { useAxiosExtension } from "@/v1/infrastructure/services/useAxiosExtension"; + +import { SchemaRepository } from "~/v2/infrastructure/repositories/SchemaRepository"; +import { useSchemas } from "~/v2/infrastructure/storage/SchemasStorage"; +import { GetSchemasUseCase } from "~/v2/domain/usecases/get-schemas-use-case"; +import { GetSchemaSettingsUseCase } from "~/v2/domain/usecases/get-schema-settings-use-case"; + +type NuxtAppLike = { + $axios: AxiosInstance; + $i18n?: { t: (key: string) => unknown }; +}; + +// Same global ts-injecty container as v1 (registrations keyed by class name — v2 names +// are disjoint from v1's by the Global Constraints rule). Called after v1's loader. +export const loadV2DependencyContainer = (nuxtApp: NuxtAppLike) => { + const t = (key: string) => String(nuxtApp.$i18n?.t(key) ?? key); + const useAxios = useAxiosExtension(nuxtApp.$axios, t); + + const dependencies = [ + register(SchemaRepository).withDependency(useAxios).build(), + register(GetSchemasUseCase).withDependencies(SchemaRepository, useSchemas).build(), + register(GetSchemaSettingsUseCase).withDependency(SchemaRepository).build(), + ]; + + Container.register(dependencies); +}; +``` + +Create `extralit-frontend/v2/di/index.ts`: + +```ts +export * from "./di"; +``` + +Modify `extralit-frontend/plugins/3.di.ts` (final file): + +```ts +import { defineNuxtPlugin } from "#app"; +import { loadDependencyContainer } from "~/v1/di"; +import { loadV2DependencyContainer } from "~/v2/di"; + +// Ordered last (3.) so $auth (1.) and $axios (2.) are available when repositories resolve. +export default defineNuxtPlugin((nuxtApp) => { + loadDependencyContainer(nuxtApp as never); + loadV2DependencyContainer(nuxtApp as never); +}); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd extralit-frontend && npx vitest run v2 --reporter=verbose` +Expected: all PASS. Also run the full suite once (`npm run test`) to prove the plugin change breaks nothing. + +- [ ] **Step 5: Boot check and commit** + +Run: `cd extralit-frontend && npm run dev` briefly (Ctrl-C after "Vite server ready") — the DI plugin must not throw at startup. + +```bash +git add v2 plugins/3.di.ts +git commit -m "feat(v2-ui): v2 DI container, SchemaRepository, schemas storage and first use-cases" +``` + +--- + +### Task 5: Records domain + V2RecordRepository + record use-cases + +**Files:** +- Create: `extralit-frontend/v2/domain/entities/record/V2Record.ts` +- Create: `extralit-frontend/v2/domain/entities/record/RecordsPage.ts` +- Create: `extralit-frontend/v2/domain/entities/search/SearchCriteria.ts` +- Create: `extralit-frontend/v2/infrastructure/repositories/V2RecordRepository.ts` +- Create: `extralit-frontend/v2/domain/usecases/get-schema-records-use-case.ts` +- Create: `extralit-frontend/v2/domain/usecases/search-records-use-case.ts` +- Create: `extralit-frontend/v2/domain/usecases/rebuild-schema-index-use-case.ts` +- Modify: `extralit-frontend/v2/di/di.ts` (add registrations) +- Test: `extralit-frontend/v2/domain/entities/search/SearchCriteria.test.ts` +- Test: `extralit-frontend/v2/infrastructure/repositories/V2RecordRepository.test.ts` + +**Interfaces:** +- Consumes: Task 3 entities, Task 2 generated types, Task 4 DI file. +- Produces: + - `type V2RecordStatus = "pending" | "completed" | "discarded"`; `class V2Record { id, schemaId, schemaVersionId, reference, externalId, fields: Record, metadata, status: V2RecordStatus, insertedAt, updatedAt }` + - `class RecordsPage { items: V2Record[]; total: number }` — `total` is documented approximate. + - `class SearchCriteria { constructor(text?: string | null, filters?: RecordFilter[], offset?: number, limit?: number); toQueryBody() }` with `interface RecordFilter { column: string; op: "eq" | "in" | "ge" | "le"; value: unknown }` + - `class V2RecordRepository { constructor(axios); getRecords(schemaId, options?: { offset?: number; limit?: number; status?: V2RecordStatus; reference?: string }): Promise; searchRecords(schemaId, criteria: SearchCriteria): Promise; rebuildIndex(schemaId): Promise }` + - Use-cases: `GetSchemaRecordsUseCase.execute(schemaId, options?) → RecordsPage`; `SearchRecordsUseCase.execute(schemaId, criteria) → RecordsPage`; `RebuildSchemaIndexUseCase.execute(schemaId) → number`. + +- [ ] **Step 1: Write the failing tests** + +Create `extralit-frontend/v2/domain/entities/search/SearchCriteria.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { SearchCriteria } from "./SearchCriteria"; + +describe("SearchCriteria serialization", () => { + it("serializes text, filters, offset and limit to the RecordSearchQuery body", () => { + const criteria = new SearchCriteria("malaria", [{ column: "status", op: "eq", value: "pending" }], 20, 10); + + expect(criteria.toQueryBody()).toEqual({ + text: "malaria", + filters: [{ column: "status", op: "eq", value: "pending" }], + offset: 20, + limit: 10, + }); + }); + + it("omits empty text as null and defaults paging", () => { + expect(new SearchCriteria("").toQueryBody()).toEqual({ text: null, filters: [], offset: 0, limit: 50 }); + }); + + it("drops ge/le filters whose value is null (server silently matches nothing, §10.1-D)", () => { + const criteria = new SearchCriteria(null, [ + { column: "score", op: "ge", value: null }, + { column: "score", op: "le", value: 5 }, + ]); + + expect(criteria.toQueryBody().filters).toEqual([{ column: "score", op: "le", value: 5 }]); + }); +}); +``` + +Create `extralit-frontend/v2/infrastructure/repositories/V2RecordRepository.test.ts`: + +```ts +import { describe, expect, it, vi } from "vitest"; +import type { AxiosInstance } from "axios"; +import { V2RecordRepository } from "./V2RecordRepository"; +import { SearchCriteria } from "~/v2/domain/entities/search/SearchCriteria"; + +const BACKEND_RECORD = { + id: "r-1", + schema_id: "s-1", + schema_version_id: "v-1", + reference: "10.1000/j.x", + external_id: null, + fields: { title: "A study" }, + metadata: null, + status: "pending", + inserted_at: "2026-01-01T00:00:00", + updated_at: "2026-01-01T00:00:00", +}; + +describe("V2RecordRepository", () => { + it("lists records with paging/reference params and maps the page", async () => { + const axios = { get: vi.fn(async () => ({ data: { items: [BACKEND_RECORD], total: 12000 } })) } as unknown as AxiosInstance; + const repository = new V2RecordRepository(axios); + + const page = await repository.getRecords("s-1", { offset: 0, limit: 25, reference: "10.1000/j.x" }); + + expect((axios.get as ReturnType).mock.calls[0]).toEqual([ + "/v2/schemas/s-1/records", + { params: { offset: 0, limit: 25, reference: "10.1000/j.x" } }, + ]); + expect(page.items[0].reference).toBe("10.1000/j.x"); + expect(page.total).toBe(12000); + }); + + it("posts search criteria to the :search custom verb", async () => { + const axios = { post: vi.fn(async () => ({ data: { items: [], total: 0 } })) } as unknown as AxiosInstance; + const repository = new V2RecordRepository(axios); + + await repository.searchRecords("s-1", new SearchCriteria("fts terms")); + + expect((axios.post as ReturnType).mock.calls[0]).toEqual([ + "/v2/schemas/s-1/records:search", + { text: "fts terms", filters: [], offset: 0, limit: 50 }, + ]); + }); + + it("returns the indexed count from :rebuild-index", async () => { + const axios = { post: vi.fn(async () => ({ data: { indexed: 42 } })) } as unknown as AxiosInstance; + const repository = new V2RecordRepository(axios); + + await expect(repository.rebuildIndex("s-1")).resolves.toBe(42); + expect(axios.post).toHaveBeenCalledWith("/v2/schemas/s-1:rebuild-index"); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd extralit-frontend && npx vitest run v2 --reporter=verbose` +Expected: new tests FAIL (modules not found); Task 3–4 tests still pass. + +- [ ] **Step 3: Write entities, repository, use-cases** + +Create `extralit-frontend/v2/domain/entities/record/V2Record.ts`: + +```ts +export type V2RecordStatus = "pending" | "completed" | "discarded"; + +export class V2Record { + constructor( + public readonly id: string, + public readonly schemaId: string, + public readonly schemaVersionId: string, + public readonly reference: string, + public readonly externalId: string | null, + public readonly fields: Record, + public readonly metadata: Record | null, + public readonly status: V2RecordStatus, + // Naive ISO strings from the server — treat as UTC (spec §7 gotchas). + public readonly insertedAt: string, + public readonly updatedAt: string + ) {} +} +``` + +Create `extralit-frontend/v2/domain/entities/record/RecordsPage.ts`: + +```ts +import { type V2Record } from "./V2Record"; + +export class RecordsPage { + constructor( + public readonly items: V2Record[], + // Approximate by contract (§10.1-D): stale Lance ids are skipped on hydration and FTS + // totals saturate at 10,000 — pagination must not promise exact counts. + public readonly total: number + ) {} +} +``` + +Create `extralit-frontend/v2/domain/entities/search/SearchCriteria.ts`: + +```ts +export type FilterOp = "eq" | "in" | "ge" | "le"; + +export interface RecordFilter { + column: string; + op: FilterOp; + value: unknown; +} + +export class SearchCriteria { + constructor( + public readonly text: string | null = null, + public readonly filters: RecordFilter[] = [], + public readonly offset: number = 0, + public readonly limit: number = 50 + ) {} + + toQueryBody() { + return { + text: this.text || null, + // ge/le with null silently matches nothing server-side — drop them here. + filters: this.filters.filter((f) => !((f.op === "ge" || f.op === "le") && f.value === null)), + offset: this.offset, + limit: this.limit, + }; + } +} +``` + +Create `extralit-frontend/v2/infrastructure/repositories/V2RecordRepository.ts`: + +```ts +import type { AxiosInstance } from "axios"; +import type { components } from "../api/generated/v2-api"; +import { V2Record, type V2RecordStatus } from "~/v2/domain/entities/record/V2Record"; +import { RecordsPage } from "~/v2/domain/entities/record/RecordsPage"; +import { SearchCriteria } from "~/v2/domain/entities/search/SearchCriteria"; + +type BackendRecord = components["schemas"]["RecordRead"]; +type BackendRecords = components["schemas"]["Records"]; + +const toRecord = (backend: BackendRecord): V2Record => + new V2Record( + backend.id, + backend.schema_id, + backend.schema_version_id, + backend.reference, + backend.external_id ?? null, + (backend.fields ?? {}) as Record, + (backend.metadata ?? null) as Record | null, + backend.status as V2RecordStatus, + backend.inserted_at, + backend.updated_at + ); + +const toPage = (backend: BackendRecords): RecordsPage => new RecordsPage(backend.items.map(toRecord), backend.total); + +export interface GetRecordsOptions { + offset?: number; + limit?: number; + status?: V2RecordStatus; + reference?: string; +} + +export class V2RecordRepository { + constructor(private readonly axios: AxiosInstance) {} + + async getRecords(schemaId: string, options: GetRecordsOptions = {}): Promise { + const { data } = await this.axios.get(`/v2/schemas/${schemaId}/records`, { params: options }); + return toPage(data); + } + + async searchRecords(schemaId: string, criteria: SearchCriteria): Promise { + const { data } = await this.axios.post(`/v2/schemas/${schemaId}/records:search`, criteria.toQueryBody()); + return toPage(data); + } + + async rebuildIndex(schemaId: string): Promise { + const { data } = await this.axios.post<{ indexed: number }>(`/v2/schemas/${schemaId}:rebuild-index`); + return data.indexed; + } +} +``` + +Create the three use-cases: + +`extralit-frontend/v2/domain/usecases/get-schema-records-use-case.ts`: + +```ts +import { RecordsPage } from "../entities/record/RecordsPage"; +import { V2RecordRepository, type GetRecordsOptions } from "~/v2/infrastructure/repositories/V2RecordRepository"; + +export class GetSchemaRecordsUseCase { + constructor(private readonly recordRepository: V2RecordRepository) {} + + execute(schemaId: string, options: GetRecordsOptions = {}): Promise { + return this.recordRepository.getRecords(schemaId, options); + } +} +``` + +`extralit-frontend/v2/domain/usecases/search-records-use-case.ts`: + +```ts +import { RecordsPage } from "../entities/record/RecordsPage"; +import { SearchCriteria } from "../entities/search/SearchCriteria"; +import { V2RecordRepository } from "~/v2/infrastructure/repositories/V2RecordRepository"; + +export class SearchRecordsUseCase { + constructor(private readonly recordRepository: V2RecordRepository) {} + + execute(schemaId: string, criteria: SearchCriteria): Promise { + return this.recordRepository.searchRecords(schemaId, criteria); + } +} +``` + +`extralit-frontend/v2/domain/usecases/rebuild-schema-index-use-case.ts`: + +```ts +import { V2RecordRepository } from "~/v2/infrastructure/repositories/V2RecordRepository"; + +export class RebuildSchemaIndexUseCase { + constructor(private readonly recordRepository: V2RecordRepository) {} + + execute(schemaId: string): Promise { + return this.recordRepository.rebuildIndex(schemaId); + } +} +``` + +Modify `extralit-frontend/v2/di/di.ts` — add imports and registrations inside `dependencies`: + +```ts +import { V2RecordRepository } from "~/v2/infrastructure/repositories/V2RecordRepository"; +import { GetSchemaRecordsUseCase } from "~/v2/domain/usecases/get-schema-records-use-case"; +import { SearchRecordsUseCase } from "~/v2/domain/usecases/search-records-use-case"; +import { RebuildSchemaIndexUseCase } from "~/v2/domain/usecases/rebuild-schema-index-use-case"; +``` + +```ts + register(V2RecordRepository).withDependency(useAxios).build(), + register(GetSchemaRecordsUseCase).withDependency(V2RecordRepository).build(), + register(SearchRecordsUseCase).withDependency(V2RecordRepository).build(), + register(RebuildSchemaIndexUseCase).withDependency(V2RecordRepository).build(), +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd extralit-frontend && npx vitest run v2 --reporter=verbose` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add v2 +git commit -m "feat(v2-ui): records domain, V2RecordRepository, search criteria and record use-cases" +``` + +--- + +### Task 6: `/schemas` list page + home nav sibling + base i18n keys + +**Files:** +- Create: `extralit-frontend/pages/schemas/index.vue` +- Create: `extralit-frontend/pages/schemas/useSchemasViewModel.ts` +- Modify: `extralit-frontend/pages/index.vue` (nav-sibling tab) +- Modify: `extralit-frontend/translation/en.js` (add `schemas.*` keys) +- Test: `extralit-frontend/pages/schemas/index.test.ts` + +**Interfaces:** +- Consumes: `GetSchemasUseCase` (Task 4) via `useResolve` from ts-injecty; `useWorkspaces()` + `Workspace` from v1 (documented exception); `useResolveMock` from `v1/di/__mocks__/useResolveMock` in tests. +- Produces: route `/schemas`; `useSchemasViewModel(): { schemas, isLoading, selectedWorkspace, loadSchemas }`. + +Note: the Nuxt `pages:extend` hook already strips non-`.vue` co-located files from routes, so `useSchemasViewModel.ts` and `index.test.ts` are safe next to the page. + +- [ ] **Step 1: Add i18n keys** + +In `extralit-frontend/translation/en.js`, add a new top-level `schemas` family (alphabetical placement near existing top-level keys): + +```js + schemas: { + title: "Schemas", + empty: "No schemas in this workspace yet.", + noWorkspace: "Select a workspace to view its schemas.", + loadError: "Could not load schemas.", + name: "Name", + status: "Status", + updatedAt: "Updated", + records: "Records", + searchPlaceholder: "Search records…", + noResults: "No records match this search.", + totalApproximate: "~{total} records", + reference: "Reference", + settings: "Settings", + columns: "Columns", + versions: "Versions", + version: "Version", + questions: "Questions", + dtype: "Type", + nullable: "Nullable", + required: "Required", + rebuildIndex: "Rebuild search index", + rebuildIndexHint: "Recovers search after a failed sync. May take tens of seconds.", + rebuildIndexDone: "Re-indexed {count} records", + }, +``` + +- [ ] **Step 2: Write the failing component test** + +Create `extralit-frontend/pages/schemas/index.test.ts`: + +```ts +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { flushPromises, shallowMount } from "@vue/test-utils"; +import { createPinia, setActivePinia } from "pinia"; +import { useResolveMock } from "~/v1/di/__mocks__/useResolveMock"; +import { GetSchemasUseCase } from "~/v2/domain/usecases/get-schemas-use-case"; +import { Schema } from "~/v2/domain/entities/schema/Schema"; +import { useWorkspaces } from "~/v1/infrastructure/storage/WorkspaceStorage"; +import { Workspace } from "~/v1/domain/entities/workspace/Workspace"; +import SchemasPage from "./index.vue"; + +const SCHEMA = new Schema("s-1", "sample_size", "published", "w-1", "v-1", {}, "2026-01-01", "2026-01-01"); + +describe("schemas list page", () => { + beforeEach(() => { + setActivePinia(createPinia()); + useWorkspaces().saveWorkspaces([new Workspace("w-1", "ws")]); + useWorkspaces().saveSelectedWorkspace(new Workspace("w-1", "ws")); + }); + + it("loads schemas for the selected workspace and renders a row per schema", async () => { + const execute = vi.fn(async () => [SCHEMA]); + useResolveMock(GetSchemasUseCase, { execute }); + + const wrapper = shallowMount(SchemasPage, { global: { stubs: { NuxtLink: { template: "" } } } }); + await flushPromises(); + + expect(execute).toHaveBeenCalledWith("w-1"); + expect(wrapper.text()).toContain("sample_size"); + }); + + it("shows the empty state when the workspace has no schemas", async () => { + useResolveMock(GetSchemasUseCase, { execute: vi.fn(async () => []) }); + + const wrapper = shallowMount(SchemasPage, { global: { stubs: { NuxtLink: true } } }); + await flushPromises(); + + expect(wrapper.text()).toContain("schemas.empty"); + }); +}); +``` + +(`$t` is mocked in `test/setup.ts` to return the key, so assertions target key names.) + +- [ ] **Step 3: Run test to verify it fails** + +Run: `cd extralit-frontend && npx vitest run pages/schemas --reporter=verbose` +Expected: FAIL — `./index.vue` not found. + +- [ ] **Step 4: Write the view-model and page** + +Create `extralit-frontend/pages/schemas/useSchemasViewModel.ts`: + +```ts +import { computed, onBeforeMount, ref, watch } from "vue"; +import { useResolve } from "ts-injecty"; +import { GetSchemasUseCase } from "~/v2/domain/usecases/get-schemas-use-case"; +import { Schema } from "~/v2/domain/entities/schema/Schema"; +// Documented v1 exception: workspace selection survives Phase 6 (see plan Global Constraints). +import { useWorkspaces } from "~/v1/infrastructure/storage/WorkspaceStorage"; + +export const useSchemasViewModel = () => { + const getSchemasUseCase = useResolve(GetSchemasUseCase); + const workspacesStore = useWorkspaces(); + + const schemas = ref([]); + const isLoading = ref(false); + const loadFailed = ref(false); + + const selectedWorkspace = computed(() => workspacesStore.get().selectedWorkspace); + + const loadSchemas = async () => { + if (!selectedWorkspace.value) return; + isLoading.value = true; + loadFailed.value = false; + try { + schemas.value = await getSchemasUseCase.execute(selectedWorkspace.value.id); + } catch { + loadFailed.value = true; // AxiosErrorHandler already notified + } finally { + isLoading.value = false; + } + }; + + onBeforeMount(loadSchemas); + watch(selectedWorkspace, loadSchemas); + + return { schemas, isLoading, loadFailed, selectedWorkspace, loadSchemas }; +}; +``` + +Create `extralit-frontend/pages/schemas/index.vue`: + +```vue + + + + + +``` + +- [ ] **Step 5: Add the nav sibling on the home page** + +In `extralit-frontend/pages/index.vue`: + +In `data()`, extend `tabs`: + +```js + tabs: [ + { id: "datasets", name: this.$t("home.datasets") }, + { id: "documents", name: this.$t("home.documents") }, + { id: "schemas", name: this.$t("schemas.title") }, + ], +``` + +In `methods.onTabChange`, navigate instead of switching for the schemas tab (final method): + +```js + onTabChange(tabId) { + if (tabId === "schemas") { + this.$router.push("/schemas"); + return; + } + const selectedTab = this.tabs.find((tab) => tab.id === tabId); + if (selectedTab) { + this.activeTab = selectedTab; + } + }, +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `cd extralit-frontend && npx vitest run pages/schemas v2 --reporter=verbose && npm run test` +Expected: new tests PASS; full suite green (home page specs unaffected — they don't assert the tab count; if one does, update its expectation to include the new tab). + +- [ ] **Step 7: Commit** + +```bash +git add pages/schemas pages/index.vue translation/en.js +git commit -m "feat(v2-ui): /schemas list page with home nav sibling and schemas.* i18n" +``` + +--- + +### Task 7: `/schemas/[id]` records table + FTS search + +**Files:** +- Create: `extralit-frontend/pages/schemas/[id]/index.vue` +- Create: `extralit-frontend/pages/schemas/[id]/useSchemaRecordsViewModel.ts` +- Create: `extralit-frontend/components/v2/schemas/V2RecordsTable.vue` +- Test: `extralit-frontend/pages/schemas/[id]/useSchemaRecordsViewModel.test.ts` + +**Interfaces:** +- Consumes: `GetSchemaRecordsUseCase`, `SearchRecordsUseCase`, `GetSchemaSettingsUseCase` (Tasks 4–5), `SearchCriteria`, `RecordsPage`. +- Produces: route `/schemas/[id]`; `useSchemaRecordsViewModel(schemaId: string): { schema, columns, page, isLoading, searchText, statusFilter, currentOffset, pageSize, search, goToOffset, isApproximateTotal }`; component `V2RecordsTable` (props: `records: V2Record[]`, `columns: ColumnMeta[]`; renders `reference` link column + one column per ColumnMeta from `record.fields`). + +Design notes locked in: +- Filter UI offers ONLY `status` (known enum) in this slice — arbitrary column filters invite the unknown-column 5xx (§10.1-D). Column filters come later once the UI derives them from `columns_cache`. +- Empty `searchText` + no filter → `GET /records` listing (exact total); otherwise `POST :search` (approximate total). `isApproximateTotal` = true on the search path; the template renders `schemas.totalApproximate` then. +- Search may miss just-written records (eventual consistency) — the empty state copy is neutral (`schemas.noResults`), never "0 records exist". + +- [ ] **Step 1: Write the failing view-model test** + +Create `extralit-frontend/pages/schemas/[id]/useSchemaRecordsViewModel.test.ts`: + +```ts +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; +import { useResolveMock } from "~/v1/di/__mocks__/useResolveMock"; +import { GetSchemaRecordsUseCase } from "~/v2/domain/usecases/get-schema-records-use-case"; +import { SearchRecordsUseCase } from "~/v2/domain/usecases/search-records-use-case"; +import { GetSchemaSettingsUseCase } from "~/v2/domain/usecases/get-schema-settings-use-case"; +import { RecordsPage } from "~/v2/domain/entities/record/RecordsPage"; +import { V2Record } from "~/v2/domain/entities/record/V2Record"; +import { Schema } from "~/v2/domain/entities/schema/Schema"; +import { SchemaVersion } from "~/v2/domain/entities/schema/SchemaVersion"; +import { ColumnMeta } from "~/v2/domain/entities/schema/ColumnMeta"; +import { useSchemaRecordsViewModel } from "./useSchemaRecordsViewModel"; + +const RECORD = new V2Record("r-1", "s-1", "v-1", "10.1000/j.x", null, { title: "A study" }, null, "pending", "", ""); +const SETTINGS = { + schema: new Schema("s-1", "sample_size", "published", "w-1", "v-1", {}, "", ""), + versions: [new SchemaVersion("v-1", "s-1", 1, [new ColumnMeta("title", "str", false, null)], {}, "")], + questions: [], +}; + +describe("useSchemaRecordsViewModel", () => { + beforeEach(() => setActivePinia(createPinia())); + + it("lists via GET when there is no query, searches via :search when there is", async () => { + const list = vi.fn(async () => new RecordsPage([RECORD], 1)); + const search = vi.fn(async () => new RecordsPage([RECORD], 1)); + useResolveMock(GetSchemaRecordsUseCase, { execute: list }); + useResolveMock(SearchRecordsUseCase, { execute: search }); + useResolveMock(GetSchemaSettingsUseCase, { execute: vi.fn(async () => SETTINGS) }); + + const vm = useSchemaRecordsViewModel("s-1"); + await vm.search(); + expect(list).toHaveBeenCalledWith("s-1", { offset: 0, limit: 25 }); + expect(vm.isApproximateTotal.value).toBe(false); + + vm.searchText.value = "malaria"; + await vm.search(); + expect(search).toHaveBeenCalled(); + expect(search.mock.calls[0][1].toQueryBody().text).toBe("malaria"); + expect(vm.isApproximateTotal.value).toBe(true); + }); + + it("passes the status filter through the search path", async () => { + const search = vi.fn(async () => new RecordsPage([], 0)); + useResolveMock(GetSchemaRecordsUseCase, { execute: vi.fn(async () => new RecordsPage([], 0)) }); + useResolveMock(SearchRecordsUseCase, { execute: search }); + useResolveMock(GetSchemaSettingsUseCase, { execute: vi.fn(async () => SETTINGS) }); + + const vm = useSchemaRecordsViewModel("s-1"); + vm.statusFilter.value = "pending"; + await vm.search(); + + expect(search.mock.calls[0][1].toQueryBody().filters).toEqual([{ column: "status", op: "eq", value: "pending" }]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd extralit-frontend && npx vitest run "pages/schemas/[id]" --reporter=verbose` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the view-model** + +Create `extralit-frontend/pages/schemas/[id]/useSchemaRecordsViewModel.ts`: + +```ts +import { computed, onBeforeMount, ref } from "vue"; +import { useResolve } from "ts-injecty"; +import { GetSchemaRecordsUseCase } from "~/v2/domain/usecases/get-schema-records-use-case"; +import { SearchRecordsUseCase } from "~/v2/domain/usecases/search-records-use-case"; +import { GetSchemaSettingsUseCase } from "~/v2/domain/usecases/get-schema-settings-use-case"; +import { RecordsPage } from "~/v2/domain/entities/record/RecordsPage"; +import { SearchCriteria, type RecordFilter } from "~/v2/domain/entities/search/SearchCriteria"; +import { Schema } from "~/v2/domain/entities/schema/Schema"; +import { ColumnMeta } from "~/v2/domain/entities/schema/ColumnMeta"; +import { type V2RecordStatus } from "~/v2/domain/entities/record/V2Record"; + +const PAGE_SIZE = 25; + +export const useSchemaRecordsViewModel = (schemaId: string) => { + const getRecordsUseCase = useResolve(GetSchemaRecordsUseCase); + const searchRecordsUseCase = useResolve(SearchRecordsUseCase); + const getSettingsUseCase = useResolve(GetSchemaSettingsUseCase); + + const schema = ref(null); + const columns = ref([]); + const page = ref(new RecordsPage([], 0)); + const isLoading = ref(false); + const searchText = ref(""); + const statusFilter = ref(""); + const currentOffset = ref(0); + + const hasQuery = computed(() => Boolean(searchText.value.trim() || statusFilter.value)); + const isApproximateTotal = ref(false); + + const loadSettings = async () => { + const settings = await getSettingsUseCase.execute(schemaId); + schema.value = settings.schema; + const currentVersion = settings.versions.find((v) => v.id === settings.schema.currentVersionId); + columns.value = currentVersion?.columnsCache ?? []; + }; + + const search = async () => { + isLoading.value = true; + try { + if (hasQuery.value) { + const filters: RecordFilter[] = statusFilter.value + ? [{ column: "status", op: "eq", value: statusFilter.value }] + : []; + const criteria = new SearchCriteria(searchText.value.trim() || null, filters, currentOffset.value, PAGE_SIZE); + page.value = await searchRecordsUseCase.execute(schemaId, criteria); + isApproximateTotal.value = true; + } else { + page.value = await getRecordsUseCase.execute(schemaId, { offset: currentOffset.value, limit: PAGE_SIZE }); + isApproximateTotal.value = false; + } + } finally { + isLoading.value = false; + } + }; + + const goToOffset = async (offset: number) => { + currentOffset.value = Math.max(0, offset); + await search(); + }; + + onBeforeMount(async () => { + await Promise.all([loadSettings(), search()]); + }); + + return { + schema, + columns, + page, + isLoading, + searchText, + statusFilter, + currentOffset, + pageSize: PAGE_SIZE, + isApproximateTotal, + search, + goToOffset, + }; +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd extralit-frontend && npx vitest run "pages/schemas/[id]" --reporter=verbose` +Expected: PASS. + +- [ ] **Step 5: Write the table component and page** + +Create `extralit-frontend/components/v2/schemas/V2RecordsTable.vue`: + +```vue + + + + + +``` + +Create `extralit-frontend/pages/schemas/[id]/index.vue`: + +```vue + + + + + +``` + +- [ ] **Step 6: Run full suite and commit** + +Run: `cd extralit-frontend && npm run test` +Expected: green. + +```bash +git add "pages/schemas/[id]" components/v2/schemas +git commit -m "feat(v2-ui): schema detail page with records table, FTS search and status filter" +``` + +--- + +### Task 8: `/schemas/[id]/settings` read-only inspection + rebuild-index + +**Files:** +- Create: `extralit-frontend/pages/schemas/[id]/settings.vue` +- Create: `extralit-frontend/pages/schemas/[id]/useSchemaSettingsViewModel.ts` +- Test: `extralit-frontend/pages/schemas/[id]/useSchemaSettingsViewModel.test.ts` + +**Interfaces:** +- Consumes: `GetSchemaSettingsUseCase`, `RebuildSchemaIndexUseCase` (Tasks 4–5); `useNotifications` from `v1/infrastructure/services` (allowed import). +- Produces: route `/schemas/[id]/settings`; `useSchemaSettingsViewModel(schemaId): { settings, isLoading, isRebuilding, rebuildIndex }`. + +- [ ] **Step 1: Write the failing test** + +Create `extralit-frontend/pages/schemas/[id]/useSchemaSettingsViewModel.test.ts`: + +```ts +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; +import { useResolveMock } from "~/v1/di/__mocks__/useResolveMock"; +import { GetSchemaSettingsUseCase } from "~/v2/domain/usecases/get-schema-settings-use-case"; +import { RebuildSchemaIndexUseCase } from "~/v2/domain/usecases/rebuild-schema-index-use-case"; +import { Schema } from "~/v2/domain/entities/schema/Schema"; +import { useSchemaSettingsViewModel } from "./useSchemaSettingsViewModel"; + +vi.mock("~/v1/infrastructure/services/useNotifications", () => ({ + useNotifications: () => ({ notify: vi.fn() }), +})); +// useTranslate calls useNuxtApp() — unavailable in the happy-dom env, so mock it too. +vi.mock("~/v1/infrastructure/services/useTranslate", () => ({ + useTranslate: () => ({ t: (key: string) => key, tc: (key: string) => key }), +})); + +const SETTINGS = { + schema: new Schema("s-1", "sample_size", "published", "w-1", "v-1", {}, "", ""), + versions: [], + questions: [], +}; + +describe("useSchemaSettingsViewModel", () => { + beforeEach(() => setActivePinia(createPinia())); + + it("loads schema settings on demand", async () => { + const execute = vi.fn(async () => SETTINGS); + useResolveMock(GetSchemaSettingsUseCase, { execute }); + useResolveMock(RebuildSchemaIndexUseCase, { execute: vi.fn() }); + + const vm = useSchemaSettingsViewModel("s-1"); + await vm.load(); + + expect(execute).toHaveBeenCalledWith("s-1"); + expect(vm.settings.value?.schema.name).toBe("sample_size"); + }); + + it("rebuild flag toggles around the (possibly slow) rebuild call", async () => { + useResolveMock(GetSchemaSettingsUseCase, { execute: vi.fn(async () => SETTINGS) }); + let resolveRebuild!: (n: number) => void; + useResolveMock(RebuildSchemaIndexUseCase, { + execute: vi.fn(() => new Promise((resolve) => (resolveRebuild = resolve))), + }); + + const vm = useSchemaSettingsViewModel("s-1"); + const pending = vm.rebuildIndex(); + expect(vm.isRebuilding.value).toBe(true); + + resolveRebuild(42); + await pending; + expect(vm.isRebuilding.value).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd extralit-frontend && npx vitest run "pages/schemas/[id]/useSchemaSettingsViewModel" --reporter=verbose` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the view-model and page** + +Create `extralit-frontend/pages/schemas/[id]/useSchemaSettingsViewModel.ts`: + +```ts +import { onBeforeMount, ref } from "vue"; +import { useResolve } from "ts-injecty"; +import { GetSchemaSettingsUseCase, type SchemaSettings } from "~/v2/domain/usecases/get-schema-settings-use-case"; +import { RebuildSchemaIndexUseCase } from "~/v2/domain/usecases/rebuild-schema-index-use-case"; +import { useNotifications } from "~/v1/infrastructure/services/useNotifications"; +import { useTranslate } from "~/v1/infrastructure/services/useTranslate"; + +export const useSchemaSettingsViewModel = (schemaId: string) => { + const getSettingsUseCase = useResolve(GetSchemaSettingsUseCase); + const rebuildIndexUseCase = useResolve(RebuildSchemaIndexUseCase); + const notifications = useNotifications(); + const { t } = useTranslate(); + + const settings = ref(null); + const isLoading = ref(false); + const isRebuilding = ref(false); + + const load = async () => { + isLoading.value = true; + try { + settings.value = await getSettingsUseCase.execute(schemaId); + } finally { + isLoading.value = false; + } + }; + + const rebuildIndex = async () => { + isRebuilding.value = true; + try { + const indexed = await rebuildIndexUseCase.execute(schemaId); + notifications.notify({ message: t("schemas.rebuildIndexDone", { count: indexed }), type: "success" }); + } finally { + isRebuilding.value = false; + } + }; + + onBeforeMount(load); + + return { settings, isLoading, isRebuilding, load, rebuildIndex }; +}; +``` + +(Signatures verified against `v1/infrastructure/services/`: `notify({ message, type })` and `useTranslate()` → `{ t, tc }` with `t(key, values?)`.) + +Create `extralit-frontend/pages/schemas/[id]/settings.vue`: + +```vue + + + + + +``` + +(Check `components/base/base-button/BaseButton.vue` for its click emit name — if it emits native `click` rather than `on-click`, bind `@click`.) + +- [ ] **Step 4: Run tests and commit** + +Run: `cd extralit-frontend && npx vitest run "pages/schemas/[id]" --reporter=verbose && npm run test` +Expected: green. + +```bash +git add "pages/schemas/[id]" +git commit -m "feat(v2-ui): read-only schema settings page with rebuild-index affordance" +``` + +--- + +### Task 9: Extract the four leaf widgets to `components/base/inputs/` + +**Files (all moves via `git mv`, then re-point imports):** +- Move: `components/features/annotation/container/questions/form/shared-components/label-selection/LabelSelection.component.vue` → `components/base/inputs/label-selection/LabelSelection.component.vue` +- Move: `.../label-selection/useLabelSelectionViewModel.ts` → `components/base/inputs/label-selection/useLabelSelectionViewModel.ts` +- Move: `.../form/rating/RatingMonoSelection.component.vue` → `components/base/inputs/rating/RatingMonoSelection.component.vue` +- Move: `.../form/ranking/drag-and-drop-selection/DndSelection.component.vue` → `components/base/inputs/ranking/DndSelection.component.vue` +- Move: `.../form/ranking/ranking-adapter.js`, `ranking-adapter.test.js`, `ranking-fakes.js` → `components/base/inputs/ranking/` +- Move: `.../form/text-area/ContentEditableFeedbackTask.vue` → `components/base/inputs/text-area/ContentEditableFeedbackTask.vue` +- Modify: v1 wrappers that import moved files by path (`Ranking.component.vue` imports `ranking-adapter`; `TextAreaContents.vue` if it imports the leaf by relative path) — component-name references (``, ``, ``, ``) keep resolving via flat auto-import and need no change. + +**Interfaces:** +- Consumes: nothing new. +- Produces: v1-free controlled leaves for Task 14. Their existing contracts (verbatim, do not change in this task): + - `LabelSelectionComponent` — props `{ maxOptionsToShowBeforeCollapse?: number, modelValue: {id,text,value,description,isSelected}[], suggestion?: object, componentId: string, multiple?: boolean, suggestionFirst?: boolean, isFocused?: boolean, visibleShortcuts?: boolean }`, emits `update:modelValue | on-focus | on-selected`. Mutates `option.isSelected` in place. + - `RatingMonoSelectionComponent` — props `{ modelValue: {id,value,isSelected}[], isFocused?, suggestion? }`, emits `update:modelValue | on-focus | on-selected`. + - `DndSelectionComponent` — props `{ ranking: object (adapter output), suggestion?, isFocused? }`, emits `on-reorder | on-focus`. `adaptQuestionsToSlots({options})` takes `{id,text,value,description,rank?}[]`, returns `{slots, questions, getRanking(option), moveQuestionToSlot(q, slot)}`. + - `ContentEditableFeedbackTask` — props `{ value: string, placeholder?, originalValue?, isFocused? }`, emits `change-text | on-change-focus | on-exit-edition-mode`. + - The `suggestion` prop duck-type all three selection leaves consume: `{ isSuggested(value): boolean; getSuggestion(value): { agent, score?: { fixed: string } } | undefined }` — Task 11's `SuggestionHint` implements it. + +- [ ] **Step 1: Baseline — run the full suite green** + +Run: `cd extralit-frontend && npm run test` +Expected: green (this is the no-regression baseline for a pure move). + +- [ ] **Step 2: Move the files** + +```bash +cd extralit-frontend +FORM=components/features/annotation/container/questions/form +mkdir -p components/base/inputs/{label-selection,rating,ranking,text-area} +git mv $FORM/shared-components/label-selection/LabelSelection.component.vue components/base/inputs/label-selection/ +git mv $FORM/shared-components/label-selection/useLabelSelectionViewModel.ts components/base/inputs/label-selection/ +git mv $FORM/rating/RatingMonoSelection.component.vue components/base/inputs/rating/ +git mv $FORM/ranking/drag-and-drop-selection/DndSelection.component.vue components/base/inputs/ranking/ +git mv $FORM/ranking/ranking-adapter.js components/base/inputs/ranking/ +git mv $FORM/ranking/ranking-adapter.test.js components/base/inputs/ranking/ +git mv $FORM/ranking/ranking-fakes.js components/base/inputs/ranking/ +git mv $FORM/text-area/ContentEditableFeedbackTask.vue components/base/inputs/text-area/ +``` + +If `shared-components/label-selection/` or `drag-and-drop-selection/` still contain other files (e.g. `SearchLabelComponent`), check whether they are used only by the moved leaf (`grep -rn "SearchLabel" components/`); if so, move them alongside; if shared more widely, leave them (auto-import keeps resolving them by name). + +- [ ] **Step 3: Re-point path imports** + +```bash +cd extralit-frontend && grep -rn "ranking-adapter\|ContentEditableFeedbackTask\|useLabelSelectionViewModel\|RatingMonoSelection\|DndSelection\|LabelSelection.component" components/ pages/ v1/ --include="*.vue" --include="*.ts" --include="*.js" | grep -v "components/base/inputs" +``` + +For every hit that imports a moved file **by path** (component-name template references need nothing), update the path, e.g. in `components/features/annotation/container/questions/form/ranking/Ranking.component.vue`: + +```js +import { adaptQuestionsToSlots } from "@/components/base/inputs/ranking/ranking-adapter"; +``` + +and in the moved leaves themselves fix any now-broken relative imports (e.g. `LabelSelection.component.vue` importing `./useLabelSelectionViewModel` still works; an import like `../../...` to form-level helpers must become an absolute `@/components/...` path). + +- [ ] **Step 4: Verify no regression** + +Run: `cd extralit-frontend && npm run test && npm run lint` +Expected: full suite green, including the moved `ranking-adapter.test.js`. Then boot `npm run dev` briefly and open a v1 dataset annotation page if a backend is available (optional smoke; the vitest suite is the gate). + +- [ ] **Step 5: Commit** + +```bash +git add -A components +git commit -m "refactor(frontend): extract v1-free leaf inputs to components/base/inputs (reuse-don't-fork)" +``` + +--- + +### Task 10: Annotation + projection repositories, value wrapping, 422 normalizer + +**Files:** +- Create: `extralit-frontend/v2/domain/entities/review/response-values.ts` +- Create: `extralit-frontend/v2/infrastructure/repositories/AnnotationRepository.ts` +- Create: `extralit-frontend/v2/infrastructure/repositories/ProjectionRepository.ts` +- Create: `extralit-frontend/v2/infrastructure/repositories/apiErrors.ts` +- Modify: `extralit-frontend/v2/di/di.ts` +- Test: `extralit-frontend/v2/domain/entities/review/response-values.test.ts` +- Test: `extralit-frontend/v2/infrastructure/repositories/AnnotationRepository.test.ts` +- Test: `extralit-frontend/v2/infrastructure/repositories/apiErrors.test.ts` + +**Interfaces:** +- Consumes: Task 2 generated types. +- Produces: + - `wrapResponseValues(values: Record): Record` and `unwrapResponseValues(wrapped: Record | null): Record` + - `interface RecordSuggestion { id, recordId, questionId, value: unknown, score: number | number[] | null, agent: string | null }` + - `interface RecordResponse { id, recordId, userId, values: Record, status: ResponseStatus }` with `type ResponseStatus = "draft" | "submitted" | "discarded"` (values are already **unwrapped** by the repository) + - `class AnnotationRepository { getSuggestions(recordId): Promise; getResponse(recordId): Promise; upsertResponse(recordId, values: Record | null, status: ResponseStatus): Promise }` — `upsertResponse` wraps values internally. + - `interface ProjectionCellDto { questionName: string; value: unknown; source: "response" | "suggestion" | null }`; `interface ProjectionRecordDto { recordId, schemaId, reference, cells: ProjectionCellDto[] }`; `class ProjectionRepository { getProjection(reference, workspaceId): Promise<{ reference: string; records: ProjectionRecordDto[]; totalRecords: number }> }` — URL uses `encodeURIComponent(reference)`. + - `normalizeV2ApiError(error: unknown): { status: number | null; messages: string[] }` — handles both 422 body shapes. + +- [ ] **Step 1: Write the failing tests** + +Create `extralit-frontend/v2/domain/entities/review/response-values.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { unwrapResponseValues, wrapResponseValues } from "./response-values"; + +describe("response value wrapping (spec §7 asymmetric-wrapping gotcha)", () => { + it("wraps plain values into {question_name: {value}}", () => { + expect(wrapResponseValues({ size: 12, label: "a" })).toEqual({ + size: { value: 12 }, + label: { value: "a" }, + }); + }); + + it("unwraps the double-wrapped GET shape", () => { + expect(unwrapResponseValues({ size: { value: 12 } })).toEqual({ size: 12 }); + }); + + it("unwraps null (no response yet) to an empty object", () => { + expect(unwrapResponseValues(null)).toEqual({}); + }); + + it("round-trips", () => { + const values = { a: [1, 2], b: { c: true } }; + expect(unwrapResponseValues(wrapResponseValues(values))).toEqual(values); + }); +}); +``` + +Create `extralit-frontend/v2/infrastructure/repositories/AnnotationRepository.test.ts`: + +```ts +import { describe, expect, it, vi } from "vitest"; +import type { AxiosInstance } from "axios"; +import { AnnotationRepository } from "./AnnotationRepository"; + +describe("AnnotationRepository", () => { + it("returns null when GET responses returns literal null with 200 (never 404)", async () => { + const axios = { get: vi.fn(async () => ({ data: null })) } as unknown as AxiosInstance; + + await expect(new AnnotationRepository(axios).getResponse("r-1")).resolves.toBeNull(); + expect(axios.get).toHaveBeenCalledWith("/v2/records/r-1/responses"); + }); + + it("unwraps response values on read", async () => { + const axios = { + get: vi.fn(async () => ({ + data: { id: "resp-1", record_id: "r-1", user_id: "u-1", values: { size: { value: 12 } }, status: "draft" }, + })), + } as unknown as AxiosInstance; + + const response = await new AnnotationRepository(axios).getResponse("r-1"); + + expect(response?.values).toEqual({ size: 12 }); + expect(response?.status).toBe("draft"); + }); + + it("re-wraps values on upsert PUT", async () => { + const put = vi.fn(async () => ({ + data: { id: "resp-1", record_id: "r-1", user_id: "u-1", values: { size: { value: 12 } }, status: "submitted" }, + })); + const axios = { put } as unknown as AxiosInstance; + + await new AnnotationRepository(axios).upsertResponse("r-1", { size: 12 }, "submitted"); + + expect(put).toHaveBeenCalledWith("/v2/records/r-1/responses", { + values: { size: { value: 12 } }, + status: "submitted", + }); + }); + + it("maps suggestions keeping question_id keying and provenance", async () => { + const axios = { + get: vi.fn(async () => ({ + data: { items: [{ id: "sug-1", record_id: "r-1", question_id: "q-1", value: 3, score: 0.9, agent: "gpt", type: null }] }, + })), + } as unknown as AxiosInstance; + + const suggestions = await new AnnotationRepository(axios).getSuggestions("r-1"); + + expect(suggestions[0]).toMatchObject({ questionId: "q-1", value: 3, score: 0.9, agent: "gpt" }); + }); +}); +``` + +Create `extralit-frontend/v2/infrastructure/repositories/apiErrors.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { normalizeV2ApiError } from "./apiErrors"; + +const axiosError = (status: number, data: unknown) => ({ isAxiosError: true, response: { status, data } }); + +describe("normalizeV2ApiError (two 422 body shapes, spec §7)", () => { + it("handles the domain-error string shape", () => { + const normalized = normalizeV2ApiError(axiosError(422, { detail: "missing value for required question: size" })); + expect(normalized).toEqual({ status: 422, messages: ["missing value for required question: size"] }); + }); + + it("handles the pydantic array shape", () => { + const normalized = normalizeV2ApiError( + axiosError(422, { detail: [{ loc: ["body", "values"], msg: "field required", type: "missing" }] }) + ); + expect(normalized).toEqual({ status: 422, messages: ["body.values: field required"] }); + }); + + it("falls back for non-axios errors", () => { + expect(normalizeV2ApiError(new Error("boom"))).toEqual({ status: null, messages: ["boom"] }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd extralit-frontend && npx vitest run v2 --reporter=verbose` +Expected: new tests FAIL (modules not found). + +- [ ] **Step 3: Write the modules** + +Create `extralit-frontend/v2/domain/entities/review/response-values.ts`: + +```ts +// The server stores and returns response values double-wrapped: {question_name: {"value": ...}} +// on BOTH PUT and GET, while projection cells are bare (spec §7 asymmetric-wrapping gotcha). +export const wrapResponseValues = (values: Record): Record => + Object.fromEntries(Object.entries(values).map(([name, value]) => [name, { value }])); + +export const unwrapResponseValues = ( + wrapped: Record | null | undefined +): Record => + Object.fromEntries(Object.entries(wrapped ?? {}).map(([name, box]) => [name, box?.value])); +``` + +Create `extralit-frontend/v2/infrastructure/repositories/AnnotationRepository.ts`: + +```ts +import type { AxiosInstance } from "axios"; +import type { components } from "../api/generated/v2-api"; +import { unwrapResponseValues, wrapResponseValues } from "~/v2/domain/entities/review/response-values"; + +type BackendSuggestions = components["schemas"]["Suggestions"]; +type BackendResponse = components["schemas"]["ResponseRead"]; + +export type ResponseStatus = "draft" | "submitted" | "discarded"; + +export interface RecordSuggestion { + id: string; + recordId: string; + questionId: string; // suggestions key by question ID, not name (spec §7 asymmetric keying) + value: unknown; + score: number | number[] | null; + agent: string | null; +} + +export interface RecordResponse { + id: string; + recordId: string; + userId: string; + values: Record; // unwrapped; keyed by question NAME + status: ResponseStatus; +} + +const toResponse = (backend: BackendResponse): RecordResponse => ({ + id: backend.id, + recordId: backend.record_id, + userId: backend.user_id, + values: unwrapResponseValues(backend.values as Record | null), + status: backend.status as ResponseStatus, +}); + +export class AnnotationRepository { + constructor(private readonly axios: AxiosInstance) {} + + async getSuggestions(recordId: string): Promise { + const { data } = await this.axios.get(`/v2/records/${recordId}/suggestions`); + return data.items.map((s) => ({ + id: s.id, + recordId: s.record_id, + questionId: s.question_id, + value: s.value, + score: (s.score ?? null) as number | number[] | null, + agent: s.agent ?? null, + })); + } + + async getResponse(recordId: string): Promise { + // 200 with literal null body when the user has no response yet — never a 404. + const { data } = await this.axios.get(`/v2/records/${recordId}/responses`); + return data ? toResponse(data) : null; + } + + async upsertResponse( + recordId: string, + values: Record | null, + status: ResponseStatus + ): Promise { + const body = { values: values ? wrapResponseValues(values) : null, status }; + const { data } = await this.axios.put(`/v2/records/${recordId}/responses`, body); + return toResponse(data); + } +} +``` + +Create `extralit-frontend/v2/infrastructure/repositories/ProjectionRepository.ts`: + +```ts +import type { AxiosInstance } from "axios"; +import type { components } from "../api/generated/v2-api"; + +type BackendProjectionView = components["schemas"]["ProjectionView"]; + +export interface ProjectionCellDto { + questionName: string; + value: unknown; + source: "response" | "suggestion" | null; +} + +export interface ProjectionRecordDto { + recordId: string; + schemaId: string; + reference: string; + cells: ProjectionCellDto[]; +} + +export interface ProjectionViewDto { + reference: string; + records: ProjectionRecordDto[]; + totalRecords: number; +} + +export class ProjectionRepository { + constructor(private readonly axios: AxiosInstance) {} + + async getProjection(reference: string, workspaceId: string): Promise { + // DOIs contain slashes — always percent-encode the path param (spec §7 / seam B). + const { data } = await this.axios.get( + `/v2/projection/references/${encodeURIComponent(reference)}`, + { params: { workspace_id: workspaceId } } + ); + return { + reference: data.reference, + totalRecords: data.total_records, + records: data.records.map((r) => ({ + recordId: r.record_id, + schemaId: r.schema_id, + reference: r.reference, + cells: r.cells.map((c) => ({ + questionName: c.question_name, + value: c.value ?? null, + source: (c.source ?? null) as "response" | "suggestion" | null, + })), + })), + }; + } +} +``` + +Create `extralit-frontend/v2/infrastructure/repositories/apiErrors.ts`: + +```ts +export interface V2ApiError { + status: number | null; + messages: string[]; +} + +interface PydanticDetail { + loc: (string | number)[]; + msg: string; + type: string; +} + +// v2 endpoints return two 422 body shapes (spec §7): domain errors {"detail": ""} +// and pydantic request errors {"detail": [{loc, msg, type}]}. Normalize both. +export const normalizeV2ApiError = (error: unknown): V2ApiError => { + const maybeAxios = error as { isAxiosError?: boolean; response?: { status: number; data?: { detail?: unknown } } }; + + if (maybeAxios?.isAxiosError && maybeAxios.response) { + const { status, data } = maybeAxios.response; + const detail = data?.detail; + + if (typeof detail === "string") return { status, messages: [detail] }; + if (Array.isArray(detail)) { + return { + status, + messages: (detail as PydanticDetail[]).map((d) => `${(d.loc ?? []).join(".")}: ${d.msg}`), + }; + } + return { status, messages: [`Request failed with status ${status}`] }; + } + + return { status: null, messages: [error instanceof Error ? error.message : String(error)] }; +}; +``` + +Modify `extralit-frontend/v2/di/di.ts` — add imports and registrations: + +```ts +import { AnnotationRepository } from "~/v2/infrastructure/repositories/AnnotationRepository"; +import { ProjectionRepository } from "~/v2/infrastructure/repositories/ProjectionRepository"; +``` + +```ts + register(AnnotationRepository).withDependency(useAxios).build(), + register(ProjectionRepository).withDependency(useAxios).build(), +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd extralit-frontend && npx vitest run v2 --reporter=verbose` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add v2 +git commit -m "feat(v2-ui): annotation/projection repositories, value re-wrapping and 422 normalizer" +``` + +--- + +### Task 11: `ReferenceReview` domain, assembly use-case, review storage + +**Files:** +- Create: `extralit-frontend/v2/domain/entities/review/ReferenceReview.ts` +- Create: `extralit-frontend/v2/domain/entities/review/SuggestionHint.ts` +- Create: `extralit-frontend/v2/domain/usecases/get-reference-review-use-case.ts` +- Create: `extralit-frontend/v2/infrastructure/storage/ReferenceReviewsStorage.ts` +- Modify: `extralit-frontend/v2/di/di.ts` +- Test: `extralit-frontend/v2/domain/usecases/get-reference-review-use-case.test.ts` +- Test: `extralit-frontend/v2/domain/entities/review/SuggestionHint.test.ts` + +**Interfaces:** +- Consumes: Tasks 3, 4, 5, 10 (SchemaRepository, V2RecordRepository, ProjectionRepository, AnnotationRepository, entities). +- Produces: + - `interface Provenance { agent: string | null; score: number | null; suggestedValue: unknown }` (score flattened: first element when the server sends a list) + - `class ReviewCell { question: Question; value: unknown; source: "response" | "suggestion" | null; provenance: Provenance | null; notApplicable: boolean }` + - `interface ContextField { column: ColumnMeta; value: unknown }` + - `interface OrphanedValue { name: string; value: unknown }` + - `class ReviewRecord { recordId, schemaId, schemaName, cells: ReviewCell[], contextFields: ContextField[], orphanedValues: OrphanedValue[], draft: RecordResponse | null, columnsCache: ColumnMeta[] = []; initialValues(): Record }` + - `class ReferenceReview { reference: string; records: ReviewRecord[]; totalRecords: number }` + - `class SuggestionHint { constructor(suggestedValue: unknown, agent: string | null, score: number | null, multiple: boolean); isSuggested(v): boolean; getSuggestion(v): { agent, score?: { fixed: string } } | undefined }` — implements the leaf-widget `suggestion` duck-type (Task 9). + - `class GetReferenceReviewUseCase { execute(reference: string, workspaceId: string): Promise }` — parallel fetches, saves to storage. + - `useReferenceReviews()` storage: `{ ...store, saveReview(review: ReferenceReview): void, findByReference(reference: string): ReferenceReview | undefined }` — keyed by reference, NOT by route, so Phase 5's Queue UI can drive it. + +- [ ] **Step 1: Write the failing tests** + +Create `extralit-frontend/v2/domain/entities/review/SuggestionHint.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { SuggestionHint } from "./SuggestionHint"; + +describe("SuggestionHint (leaf-widget suggestion duck-type)", () => { + it("matches scalar values for single-select widgets", () => { + const hint = new SuggestionHint("malaria", "gpt-4", 0.87, false); + + expect(hint.isSuggested("malaria")).toBe(true); + expect(hint.isSuggested("dengue")).toBe(false); + expect(hint.getSuggestion("malaria")).toEqual({ agent: "gpt-4", score: { fixed: "0.9" } }); + }); + + it("matches membership for multi-select widgets", () => { + const hint = new SuggestionHint(["a", "b"], null, null, true); + + expect(hint.isSuggested("a")).toBe(true); + expect(hint.isSuggested("c")).toBe(false); + expect(hint.getSuggestion("a")).toEqual({ agent: null, score: undefined }); + }); +}); +``` + +Create `extralit-frontend/v2/domain/usecases/get-reference-review-use-case.test.ts`: + +```ts +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; +import { GetReferenceReviewUseCase } from "./get-reference-review-use-case"; +import { Question } from "../entities/question/Question"; +import { Schema } from "../entities/schema/Schema"; +import { SchemaVersion } from "../entities/schema/SchemaVersion"; +import { ColumnMeta } from "../entities/schema/ColumnMeta"; +import { V2Record } from "../entities/record/V2Record"; +import { RecordsPage } from "../entities/record/RecordsPage"; +import { useReferenceReviews } from "~/v2/infrastructure/storage/ReferenceReviewsStorage"; + +const REFERENCE = "10.1000/j.x"; +const WORKSPACE = "w-1"; + +const sizeQuestion = new Question("q-size", "s-1", "size", "Sample size", null, "text", ["size"], {}, true); +const record = new V2Record("r-1", "s-1", "v-1", REFERENCE, null, { size: "12", country: "KE" }, null, "pending", "", ""); + +const projectionRepository = { + getProjection: vi.fn(async () => ({ + reference: REFERENCE, + totalRecords: 1, + records: [ + { + recordId: "r-1", + schemaId: "s-1", + reference: REFERENCE, + cells: [{ questionName: "size", value: "12", source: "suggestion" as const }], + }, + ], + })), +}; + +const schemaRepository = { + getSchema: vi.fn(async () => new Schema("s-1", "sample_size", "published", WORKSPACE, "v-1", {}, "", "")), + getQuestions: vi.fn(async () => [sizeQuestion]), + getVersions: vi.fn(async () => [ + new SchemaVersion( + "v-1", + "s-1", + 1, + [new ColumnMeta("size", "str", false, null), new ColumnMeta("country", "str", true, null)], + {}, + "" + ), + ]), +}; + +const recordRepository = { getRecords: vi.fn(async () => new RecordsPage([record], 1)) }; + +const annotationRepository = { + getSuggestions: vi.fn(async () => [ + { id: "sug-1", recordId: "r-1", questionId: "q-size", value: "12", score: 0.9, agent: "gpt" }, + ]), + getResponse: vi.fn(async () => null), +}; + +const makeUseCase = () => + new GetReferenceReviewUseCase( + projectionRepository as never, + schemaRepository as never, + recordRepository as never, + annotationRepository as never, + useReferenceReviews + ); + +describe("GetReferenceReviewUseCase", () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + }); + + it("joins suggestion provenance to cells through the name↔id question map", async () => { + const review = await makeUseCase().execute(REFERENCE, WORKSPACE); + + const cell = review.records[0].cells.find((c) => c.question.name === "size")!; + expect(cell.source).toBe("suggestion"); + expect(cell.provenance).toEqual({ agent: "gpt", score: 0.9, suggestedValue: "12" }); + }); + + it("exposes non-question columns as read-only context fields", async () => { + const review = await makeUseCase().execute(REFERENCE, WORKSPACE); + + expect(review.records[0].contextFields).toEqual([ + { column: expect.objectContaining({ name: "country" }), value: "KE" }, + ]); + }); + + it("marks a question not-applicable when its column is missing from the pinned version cache", async () => { + schemaRepository.getQuestions.mockResolvedValueOnce([ + sizeQuestion, + new Question("q-new", "s-1", "added_later", "Added later", null, "text", ["added_later"], {}, false), + ]); + + const review = await makeUseCase().execute(REFERENCE, WORKSPACE); + + expect(review.records[0].cells.find((c) => c.question.name === "added_later")?.notApplicable).toBe(true); + }); + + it("collects response values orphaned by deleted questions and keeps a draft for prefill", async () => { + annotationRepository.getResponse.mockResolvedValueOnce({ + id: "resp-1", + recordId: "r-1", + userId: "u-1", + values: { size: "13", ghost_question: "zzz" }, + status: "draft", + }); + + const review = await makeUseCase().execute(REFERENCE, WORKSPACE); + const reviewRecord = review.records[0]; + + expect(reviewRecord.orphanedValues).toEqual([{ name: "ghost_question", value: "zzz" }]); + expect(reviewRecord.draft?.status).toBe("draft"); + // draft wins over the projection cell for prefill; orphans are excluded + expect(reviewRecord.initialValues()).toEqual({ size: "13" }); + }); + + it("prefills from the projection cell when there is no draft, and saves to storage by reference", async () => { + const review = await makeUseCase().execute(REFERENCE, WORKSPACE); + + expect(review.records[0].initialValues()).toEqual({ size: "12" }); + expect(useReferenceReviews().findByReference(REFERENCE)).toBe(review); + }); + + it("ignores a submitted response for prefill (projection already reflects it)", async () => { + annotationRepository.getResponse.mockResolvedValueOnce({ + id: "resp-1", + recordId: "r-1", + userId: "u-1", + values: { size: "12" }, + status: "submitted", + }); + + const review = await makeUseCase().execute(REFERENCE, WORKSPACE); + + expect(review.records[0].draft).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd extralit-frontend && npx vitest run v2 --reporter=verbose` +Expected: new tests FAIL. + +- [ ] **Step 3: Write entity, hint, storage, use-case** + +Create `extralit-frontend/v2/domain/entities/review/ReferenceReview.ts`: + +```ts +import { Question } from "../question/Question"; +import { type ColumnMeta } from "../schema/ColumnMeta"; +import { type RecordResponse } from "~/v2/infrastructure/repositories/AnnotationRepository"; + +export interface Provenance { + agent: string | null; + score: number | null; + suggestedValue: unknown; +} + +export class ReviewCell { + constructor( + public readonly question: Question, + public readonly value: unknown, + public readonly source: "response" | "suggestion" | null, + public readonly provenance: Provenance | null, + // The question binds a column absent from this record's pinned version cache (§17.3). + public readonly notApplicable: boolean + ) {} +} + +export interface ContextField { + column: ColumnMeta; + value: unknown; +} + +// Response values keyed by a name no current question owns (deleted/recreated question, +// spec §10.1-E): surfaced read-only, never re-submitted (server would 422 them). +export interface OrphanedValue { + name: string; + value: unknown; +} + +export class ReviewRecord { + constructor( + public readonly recordId: string, + public readonly schemaId: string, + public readonly schemaName: string, + public readonly cells: ReviewCell[], + public readonly contextFields: ContextField[], + public readonly orphanedValues: OrphanedValue[], + public readonly draft: RecordResponse | null, // status === "draft" only + // Pinned version's columns_cache — table-question sub-columns derive editors from it. + public readonly columnsCache: ColumnMeta[] = [] + ) {} + + initialValues(): Record { + const values: Record = {}; + for (const cell of this.cells) { + if (cell.notApplicable) continue; + const draftValue = this.draft?.values[cell.question.name]; + const value = draftValue !== undefined ? draftValue : cell.value; + if (value !== null && value !== undefined) values[cell.question.name] = value; + } + return values; + } +} + +export class ReferenceReview { + constructor( + public readonly reference: string, + public readonly records: ReviewRecord[], + public readonly totalRecords: number + ) {} +} +``` + +Create `extralit-frontend/v2/domain/entities/review/SuggestionHint.ts`: + +```ts +// Implements the duck-type the extracted leaf widgets consume as their `suggestion` prop: +// isSuggested(value) / getSuggestion(value) -> { agent, score?: { fixed } } (see Task 9 contract). +export class SuggestionHint { + constructor( + private readonly suggestedValue: unknown, + private readonly agent: string | null, + private readonly score: number | null, + private readonly multiple: boolean + ) {} + + isSuggested(value: unknown): boolean { + if (this.multiple && Array.isArray(this.suggestedValue)) { + return (this.suggestedValue as unknown[]).includes(value); + } + return this.suggestedValue === value; + } + + getSuggestion(value: unknown): { agent: string | null; score?: { fixed: string } } | undefined { + if (!this.isSuggested(value)) return undefined; + return { agent: this.agent, score: this.score != null ? { fixed: this.score.toFixed(1) } : undefined }; + } +} +``` + +Create `extralit-frontend/v2/infrastructure/storage/ReferenceReviewsStorage.ts`: + +```ts +import { useStoreFor } from "@/v1/store/create"; +import { ReferenceReview } from "~/v2/domain/entities/review/ReferenceReview"; + +// Keyed by reference, not by route, so Phase 5's Queue UI can drive the same store +// with references from GET /queues/{id}/next (spec §7). +class ReferenceReviews { + constructor(public readonly byReference: Record = {}) {} +} + +interface IReferenceReviewsStorage { + saveReview(review: ReferenceReview): void; + findByReference(reference: string): ReferenceReview | undefined; +} + +const useStoreForReferenceReviews = useStoreFor(ReferenceReviews); + +export const useReferenceReviews = () => { + const store = useStoreForReferenceReviews(); + + const saveReview = (review: ReferenceReview) => { + store.save(new ReferenceReviews({ ...store.get().byReference, [review.reference]: review })); + }; + + const findByReference = (reference: string): ReferenceReview | undefined => store.get().byReference[reference]; + + return { ...store, saveReview, findByReference }; +}; +``` + +Create `extralit-frontend/v2/domain/usecases/get-reference-review-use-case.ts`: + +```ts +import { Question } from "../entities/question/Question"; +import { SchemaVersion } from "../entities/schema/SchemaVersion"; +import { + type ContextField, + type OrphanedValue, + type Provenance, + ReferenceReview, + ReviewCell, + ReviewRecord, +} from "../entities/review/ReferenceReview"; +import { SchemaRepository } from "~/v2/infrastructure/repositories/SchemaRepository"; +import { V2RecordRepository } from "~/v2/infrastructure/repositories/V2RecordRepository"; +import { ProjectionRepository, type ProjectionRecordDto } from "~/v2/infrastructure/repositories/ProjectionRepository"; +import { AnnotationRepository, type RecordSuggestion } from "~/v2/infrastructure/repositories/AnnotationRepository"; +import { type useReferenceReviews } from "~/v2/infrastructure/storage/ReferenceReviewsStorage"; + +interface SchemaContext { + schemaName: string; + questions: Question[]; + questionsById: Map; + questionsByName: Map; + versionsById: Map; +} + +export class GetReferenceReviewUseCase { + constructor( + private readonly projectionRepository: ProjectionRepository, + private readonly schemaRepository: SchemaRepository, + private readonly recordRepository: V2RecordRepository, + private readonly annotationRepository: AnnotationRepository, + private readonly reviewsStorage: typeof useReferenceReviews + ) {} + + async execute(reference: string, workspaceId: string): Promise { + const projection = await this.projectionRepository.getProjection(reference, workspaceId); + + const schemaIds = [...new Set(projection.records.map((r) => r.schemaId))]; + const contexts = new Map(); + const recordsBySchema = new Map>>(); + const versionByRecord = new Map(); + + await Promise.all( + schemaIds.map(async (schemaId) => { + const [schema, questions, versions, page] = await Promise.all([ + this.schemaRepository.getSchema(schemaId), + this.schemaRepository.getQuestions(schemaId), + this.schemaRepository.getVersions(schemaId), + this.recordRepository.getRecords(schemaId, { reference }), + ]); + contexts.set(schemaId, { + schemaName: schema.name, + questions, + // The name↔id join: projection cells + response values key by NAME, + // suggestions key by ID (spec §7). Getting this wrong detaches provenance. + questionsById: new Map(questions.map((q) => [q.id, q])), + questionsByName: new Map(questions.map((q) => [q.name, q])), + versionsById: new Map(versions.map((v) => [v.id, v])), + }); + recordsBySchema.set(schemaId, new Map(page.items.map((r) => [r.id, r.fields]))); + page.items.forEach((r) => versionByRecord.set(r.id, r.schemaVersionId)); + }) + ); + + const reviewRecords = await Promise.all( + projection.records.map((projected) => this.assembleRecord(projected, contexts, recordsBySchema, versionByRecord)) + ); + + const review = new ReferenceReview(reference, reviewRecords, projection.totalRecords); + this.reviewsStorage().saveReview(review); + return review; + } + + private async assembleRecord( + projected: ProjectionRecordDto, + contexts: Map, + recordsBySchema: Map>>, + versionByRecord: Map + ): Promise { + const context = contexts.get(projected.schemaId)!; + const fields = recordsBySchema.get(projected.schemaId)?.get(projected.recordId) ?? {}; + const pinnedVersion = context.versionsById.get(versionByRecord.get(projected.recordId) ?? ""); + + const [suggestions, response] = await Promise.all([ + this.annotationRepository.getSuggestions(projected.recordId), + this.annotationRepository.getResponse(projected.recordId), + ]); + const suggestionsByQuestionId = new Map(suggestions.map((s) => [s.questionId, s])); + const cellsByName = new Map(projected.cells.map((c) => [c.questionName, c])); + + const cells = context.questions.map((question) => { + const cell = cellsByName.get(question.name); + const suggestion = suggestionsByQuestionId.get(question.id); + const provenance: Provenance | null = suggestion + ? { + agent: suggestion.agent, + score: Array.isArray(suggestion.score) ? (suggestion.score[0] ?? null) : suggestion.score, + suggestedValue: suggestion.value, + } + : null; + // Old-version tolerance (§17.3): every bound column must exist in the pinned cache. + const notApplicable = + pinnedVersion !== undefined && question.columns.some((c) => pinnedVersion.findColumn(c) === undefined); + + return new ReviewCell(question, cell?.value ?? null, cell?.source ?? null, provenance, notApplicable); + }); + + const questionColumns = new Set(context.questions.flatMap((q) => q.columns)); + const contextFields: ContextField[] = (pinnedVersion?.columnsCache ?? []) + .filter((column) => !questionColumns.has(column.name)) + .map((column) => ({ column, value: fields[column.name] ?? null })); + + const orphanedValues: OrphanedValue[] = Object.entries(response?.values ?? {}) + .filter(([name]) => !context.questionsByName.has(name)) + .map(([name, value]) => ({ name, value })); + + const draft = response?.status === "draft" ? response : null; + + return new ReviewRecord( + projected.recordId, + projected.schemaId, + context.schemaName, + cells, + contextFields, + orphanedValues, + draft, + pinnedVersion?.columnsCache ?? [] + ); + } +} +``` + +Modify `extralit-frontend/v2/di/di.ts` — add: + +```ts +import { GetReferenceReviewUseCase } from "~/v2/domain/usecases/get-reference-review-use-case"; +import { useReferenceReviews } from "~/v2/infrastructure/storage/ReferenceReviewsStorage"; +``` + +```ts + register(GetReferenceReviewUseCase) + .withDependencies(ProjectionRepository, SchemaRepository, V2RecordRepository, AnnotationRepository, useReferenceReviews) + .build(), +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd extralit-frontend && npx vitest run v2 --reporter=verbose` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add v2 +git commit -m "feat(v2-ui): ReferenceReview assembly with name-id join, drafts, orphans and version tolerance" +``` + +--- + +### Task 12: Submit / save-draft / discard use-cases + +**Files:** +- Create: `extralit-frontend/v2/domain/usecases/submit-reference-review-use-case.ts` +- Create: `extralit-frontend/v2/domain/usecases/save-review-draft-use-case.ts` +- Create: `extralit-frontend/v2/domain/usecases/discard-review-use-case.ts` +- Modify: `extralit-frontend/v2/di/di.ts` +- Test: `extralit-frontend/v2/domain/usecases/submit-reference-review-use-case.test.ts` + +**Interfaces:** +- Consumes: `AnnotationRepository` (Task 10), `normalizeV2ApiError` (Task 10). +- Produces (all consumed by Task 14's form → Task 15's page): + - `class SubmitReferenceReviewUseCase { execute(recordId: string, values: Record): Promise }` — status `submitted`; on failure throws `ReviewSubmitError { messages: string[] }`. + - `class SaveReviewDraftUseCase { execute(recordId, values): Promise }` — status `draft`. + - `class DiscardReviewUseCase { execute(recordId: string): Promise }` — status `discarded`, `values: null`. + - `class ReviewSubmitError extends Error { messages: string[]; status: number | null }` (exported from the submit use-case file). + +- [ ] **Step 1: Write the failing test** + +Create `extralit-frontend/v2/domain/usecases/submit-reference-review-use-case.test.ts`: + +```ts +import { describe, expect, it, vi } from "vitest"; +import { ReviewSubmitError, SubmitReferenceReviewUseCase } from "./submit-reference-review-use-case"; +import { SaveReviewDraftUseCase } from "./save-review-draft-use-case"; +import { DiscardReviewUseCase } from "./discard-review-use-case"; + +describe("review response use-cases", () => { + it("submits with status=submitted", async () => { + const upsertResponse = vi.fn(async () => ({ id: "resp", status: "submitted" })); + await new SubmitReferenceReviewUseCase({ upsertResponse } as never).execute("r-1", { size: 12 }); + + expect(upsertResponse).toHaveBeenCalledWith("r-1", { size: 12 }, "submitted"); + }); + + it("normalizes both 422 shapes into ReviewSubmitError", async () => { + const upsertResponse = vi.fn(async () => { + throw { isAxiosError: true, response: { status: 422, data: { detail: "missing value for required question" } } }; + }); + + const attempt = new SubmitReferenceReviewUseCase({ upsertResponse } as never).execute("r-1", {}); + + await expect(attempt).rejects.toBeInstanceOf(ReviewSubmitError); + await expect(attempt).rejects.toMatchObject({ messages: ["missing value for required question"], status: 422 }); + }); + + it("saves drafts with status=draft", async () => { + const upsertResponse = vi.fn(async () => ({ id: "resp", status: "draft" })); + await new SaveReviewDraftUseCase({ upsertResponse } as never).execute("r-1", { size: 12 }); + + expect(upsertResponse).toHaveBeenCalledWith("r-1", { size: 12 }, "draft"); + }); + + it("discards with null values", async () => { + const upsertResponse = vi.fn(async () => ({ id: "resp", status: "discarded" })); + await new DiscardReviewUseCase({ upsertResponse } as never).execute("r-1"); + + expect(upsertResponse).toHaveBeenCalledWith("r-1", null, "discarded"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd extralit-frontend && npx vitest run v2/domain/usecases --reporter=verbose` +Expected: FAIL — modules not found. + +- [ ] **Step 3: Write the use-cases** + +Create `extralit-frontend/v2/domain/usecases/submit-reference-review-use-case.ts`: + +```ts +import { AnnotationRepository, type RecordResponse } from "~/v2/infrastructure/repositories/AnnotationRepository"; +import { normalizeV2ApiError } from "~/v2/infrastructure/repositories/apiErrors"; + +export class ReviewSubmitError extends Error { + constructor( + public readonly messages: string[], + public readonly status: number | null + ) { + super(messages.join("; ")); + this.name = "ReviewSubmitError"; + } +} + +export class SubmitReferenceReviewUseCase { + constructor(private readonly annotationRepository: AnnotationRepository) {} + + async execute(recordId: string, values: Record): Promise { + try { + return await this.annotationRepository.upsertResponse(recordId, values, "submitted"); + } catch (error) { + const { messages, status } = normalizeV2ApiError(error); + throw new ReviewSubmitError(messages, status); + } + } +} +``` + +Create `extralit-frontend/v2/domain/usecases/save-review-draft-use-case.ts`: + +```ts +import { AnnotationRepository, type RecordResponse } from "~/v2/infrastructure/repositories/AnnotationRepository"; +import { normalizeV2ApiError } from "~/v2/infrastructure/repositories/apiErrors"; +import { ReviewSubmitError } from "./submit-reference-review-use-case"; + +export class SaveReviewDraftUseCase { + constructor(private readonly annotationRepository: AnnotationRepository) {} + + async execute(recordId: string, values: Record): Promise { + try { + return await this.annotationRepository.upsertResponse(recordId, values, "draft"); + } catch (error) { + const { messages, status } = normalizeV2ApiError(error); + throw new ReviewSubmitError(messages, status); + } + } +} +``` + +Create `extralit-frontend/v2/domain/usecases/discard-review-use-case.ts`: + +```ts +import { AnnotationRepository, type RecordResponse } from "~/v2/infrastructure/repositories/AnnotationRepository"; +import { normalizeV2ApiError } from "~/v2/infrastructure/repositories/apiErrors"; +import { ReviewSubmitError } from "./submit-reference-review-use-case"; + +export class DiscardReviewUseCase { + constructor(private readonly annotationRepository: AnnotationRepository) {} + + async execute(recordId: string): Promise { + try { + // Discarding reverts the projection cell to the suggestion (server filters submitted only). + return await this.annotationRepository.upsertResponse(recordId, null, "discarded"); + } catch (error) { + const { messages, status } = normalizeV2ApiError(error); + throw new ReviewSubmitError(messages, status); + } + } +} +``` + +Modify `extralit-frontend/v2/di/di.ts` — add: + +```ts +import { SubmitReferenceReviewUseCase } from "~/v2/domain/usecases/submit-reference-review-use-case"; +import { SaveReviewDraftUseCase } from "~/v2/domain/usecases/save-review-draft-use-case"; +import { DiscardReviewUseCase } from "~/v2/domain/usecases/discard-review-use-case"; +``` + +```ts + register(SubmitReferenceReviewUseCase).withDependency(AnnotationRepository).build(), + register(SaveReviewDraftUseCase).withDependency(AnnotationRepository).build(), + register(DiscardReviewUseCase).withDependency(AnnotationRepository).build(), +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd extralit-frontend && npx vitest run v2 --reporter=verbose` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add v2 +git commit -m "feat(v2-ui): submit/save-draft/discard review use-cases with 422 normalization" +``` + +--- + +### Task 13: Lean `V2TableEditor` (controlled tabulator wrapper, ~300 LOC budget) + +**Files:** +- Create: `extralit-frontend/components/v2/table/V2TableEditor.vue` +- Test: `extralit-frontend/components/v2/table/V2TableEditor.test.ts` + +**Interfaces:** +- Consumes: `ColumnMeta`, `columnCellEditor` (Task 3); `tabulator-tables` (already a dependency; **mocked in vitest** via `__mocks__/tabulator-tables.js` alias — component tests assert wiring/emits, not real tabulator rendering). +- Produces: ``. A `table` question's value is a JSONB dict with keys ⊆ bound columns (structure-only server validation) — rendered as a single-row editable grid, one tabulator column per bound `ColumnMeta`. Deliberately NOT ported: reference-table lookups and LLM-extraction viewmodels (v1-coupled features, spec §5). + +- [ ] **Step 1: Write the failing test** + +Create `extralit-frontend/components/v2/table/V2TableEditor.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { ColumnMeta } from "~/v2/domain/entities/schema/ColumnMeta"; +import { tabulatorColumns, valueFromRowData } from "./V2TableEditor.vue"; + +describe("V2TableEditor column derivation", () => { + it("derives one tabulator column per bound ColumnMeta with dtype-driven editors", () => { + const columns = tabulatorColumns( + [ + new ColumnMeta("name", "str", false, null), + new ColumnMeta("count", "int64", false, null), + new ColumnMeta("done", "bool", false, null), + new ColumnMeta("when", "datetime64[ns]", true, null), + ], + true + ); + + expect(columns.map((c) => [c.field, c.editor])).toEqual([ + ["name", "input"], + ["count", "number"], + ["done", "tickCross"], + ["when", "date"], + ]); + }); + + it("honors the review overlay hint over the dtype default", () => { + const columns = tabulatorColumns([new ColumnMeta("count", "int64", false, { type: "text" })], true); + expect(columns[0].editor).toBe("input"); + }); + + it("disables editors when not editable", () => { + const columns = tabulatorColumns([new ColumnMeta("name", "str", false, null)], false); + expect(columns[0].editor).toBe(false); + }); +}); + +describe("valueFromRowData", () => { + it("keeps only bound-column keys (server validates keys ⊆ bound columns)", () => { + const value = valueFromRowData({ name: "a", stray: "x" }, [new ColumnMeta("name", "str", false, null)]); + expect(value).toEqual({ name: "a" }); + }); + + it("drops undefined cells so absent keys stay absent", () => { + const value = valueFromRowData({ name: undefined }, [new ColumnMeta("name", "str", false, null)]); + expect(value).toEqual({}); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd extralit-frontend && npx vitest run components/v2/table --reporter=verbose` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the component** + +Create `extralit-frontend/components/v2/table/V2TableEditor.vue`: + +```vue + + + + + +``` + +If the vitest tabulator mock (`__mocks__/tabulator-tables.js`) doesn't export `TabulatorFull` as a constructible class with an `on` method, extend the mock minimally (add missing no-op methods) — do not unalias the real library in tests. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd extralit-frontend && npx vitest run components/v2/table --reporter=verbose` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add components/v2/table __mocks__ +git commit -m "feat(v2-ui): lean V2TableEditor tabulator wrapper for table questions" +``` + +--- + +### Task 14: `ProjectionReviewForm` + widget adapters + review i18n + +**Files:** +- Create: `extralit-frontend/v2/domain/entities/review/widget-adapters.ts` +- Create: `extralit-frontend/components/v2/review/ProjectionReviewForm.vue` +- Create: `extralit-frontend/components/v2/review/ReviewRecordCard.vue` +- Create: `extralit-frontend/components/v2/review/ReviewCellInput.vue` +- Create: `extralit-frontend/components/v2/review/ReviewProvenance.vue` +- Modify: `extralit-frontend/translation/en.js` (add `review.*` keys) +- Test: `extralit-frontend/v2/domain/entities/review/widget-adapters.test.ts` +- Test: `extralit-frontend/components/v2/review/ProjectionReviewForm.test.ts` + +**Interfaces:** +- Consumes: `ReferenceReview` / `ReviewRecord` / `ReviewCell` / `SuggestionHint` (Task 11), extracted leaves (Task 9), `V2TableEditor` (Task 13), `Question` (Task 3). +- Produces: + - Widget adapters (pure, tested): `buildLabelOptions(question, selected: unknown): {id,text,value,description,isSelected}[]`; `selectedFromLabelOptions(options, multiple): string | string[] | null`; `buildRatingOptions(question, selected: unknown): {id,value,isSelected}[]`; `selectedFromRatingOptions(options): number | null`; `buildRankingValues(question, ranked: unknown): {id,text,value,description,rank}[]`; `rankingAnswerFromValues(values): {value,rank}[]`; `suggestionHintFor(cell: ReviewCell): SuggestionHint | null`. + - `` — **pure**: no route reads, no fetches, no queue knowledge (spec §7). `values` are PLAIN (unwrapped); the page's use-cases wrap them. + - `` also accepts `:submit-errors="Record"` (recordId → messages) so the page can surface normalized 422s inline. + +- [ ] **Step 1: Write the failing adapter tests** + +Create `extralit-frontend/v2/domain/entities/review/widget-adapters.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { Question } from "../question/Question"; +import { ReviewCell } from "./ReferenceReview"; +import { + buildLabelOptions, + buildRankingValues, + buildRatingOptions, + rankingAnswerFromValues, + selectedFromLabelOptions, + selectedFromRatingOptions, + suggestionHintFor, +} from "./widget-adapters"; + +const labelQuestion = new Question("q-1", "s-1", "label", "Label", null, "label_selection", ["label"], { + type: "label_selection", + options: [ + { value: "a", text: "A", description: null }, + { value: "b", text: "B", description: null }, + ], +}, false); + +const ratingQuestion = new Question("q-2", "s-1", "stars", "Stars", null, "rating", ["stars"], { + type: "rating", + options: [{ value: 1 }, { value: 2 }, { value: 3 }], +}, false); + +const rankingQuestion = new Question("q-3", "s-1", "rank", "Rank", null, "ranking", ["rank"], { + type: "ranking", + options: [ + { value: "x", text: "X", description: null }, + { value: "y", text: "Y", description: null }, + ], +}, false); + +describe("label adapters", () => { + it("builds leaf options with isSelected from a scalar (single) or array (multi) value", () => { + expect(buildLabelOptions(labelQuestion, "b").map((o) => o.isSelected)).toEqual([false, true]); + expect(buildLabelOptions(labelQuestion, ["a", "b"]).map((o) => o.isSelected)).toEqual([true, true]); + expect(buildLabelOptions(labelQuestion, null).map((o) => o.isSelected)).toEqual([false, false]); + }); + + it("derives the server value shape back from options", () => { + const options = buildLabelOptions(labelQuestion, "b"); + expect(selectedFromLabelOptions(options, false)).toBe("b"); + expect(selectedFromLabelOptions(options, true)).toEqual(["b"]); + expect(selectedFromLabelOptions(buildLabelOptions(labelQuestion, null), false)).toBeNull(); + }); +}); + +describe("rating adapters", () => { + it("round-trips a numeric rating", () => { + const options = buildRatingOptions(ratingQuestion, 2); + expect(options).toEqual([ + { id: "stars_1", value: 1, isSelected: false }, + { id: "stars_2", value: 2, isSelected: true }, + { id: "stars_3", value: 3, isSelected: false }, + ]); + expect(selectedFromRatingOptions(options)).toBe(2); + }); +}); + +describe("ranking adapters", () => { + it("round-trips the [{value, rank}] server shape", () => { + const values = buildRankingValues(rankingQuestion, [{ value: "y", rank: 1 }]); + expect(values.find((v) => v.value === "y")?.rank).toBe(1); + expect(values.find((v) => v.value === "x")?.rank).toBeNull(); + + values.find((v) => v.value === "x")!.rank = 2; + expect(rankingAnswerFromValues(values)).toEqual([ + { value: "y", rank: 1 }, + { value: "x", rank: 2 }, + ]); + }); +}); + +describe("suggestionHintFor", () => { + it("returns a hint only for suggestion-sourced cells", () => { + const suggested = new ReviewCell(labelQuestion, "a", "suggestion", { agent: "gpt", score: 0.5, suggestedValue: "a" }, false); + const responded = new ReviewCell(labelQuestion, "a", "response", null, false); + + expect(suggestionHintFor(suggested)?.isSuggested("a")).toBe(true); + expect(suggestionHintFor(responded)).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail, then write the adapters** + +Run: `cd extralit-frontend && npx vitest run v2/domain/entities/review --reporter=verbose` — expected FAIL. + +Create `extralit-frontend/v2/domain/entities/review/widget-adapters.ts`: + +```ts +import { Question } from "../question/Question"; +import { type ReviewCell } from "./ReferenceReview"; +import { SuggestionHint } from "./SuggestionHint"; + +// Adapters between server value shapes and the extracted leaf-widget option shapes +// (see Task 9 contracts). Ids follow v1's `${questionName}_${value}` convention. + +export interface LabelOption { + id: string; + text: string; + value: string; + description: string | null; + isSelected: boolean; +} + +export const buildLabelOptions = (question: Question, selected: unknown): LabelOption[] => { + const selectedValues = Array.isArray(selected) ? (selected as string[]) : selected != null ? [selected as string] : []; + return question.options.map((option) => ({ + id: `${question.name}_${option.value}`, + text: option.text, + value: option.value, + description: option.description, + isSelected: selectedValues.includes(option.value), + })); +}; + +export const selectedFromLabelOptions = (options: LabelOption[], multiple: boolean): string | string[] | null => { + const selected = options.filter((o) => o.isSelected).map((o) => o.value); + if (multiple) return selected; + return selected[0] ?? null; +}; + +export interface RatingOption { + id: string; + value: number; + isSelected: boolean; +} + +export const buildRatingOptions = (question: Question, selected: unknown): RatingOption[] => + question.ratingValues.map((value) => ({ + id: `${question.name}_${value}`, + value, + isSelected: selected === value, + })); + +export const selectedFromRatingOptions = (options: RatingOption[]): number | null => + options.find((o) => o.isSelected)?.value ?? null; + +export interface RankingValue { + id: string; + text: string; + value: string; + description: string | null; + rank: number | null; +} + +export const buildRankingValues = (question: Question, ranked: unknown): RankingValue[] => { + const ranks = new Map( + Array.isArray(ranked) ? (ranked as { value: string; rank: number }[]).map((r) => [r.value, r.rank]) : [] + ); + return question.options.map((option) => ({ + id: `${question.name}_${option.value}`, + text: option.text, + value: option.value, + description: option.description, + rank: ranks.get(option.value) ?? null, + })); +}; + +export const rankingAnswerFromValues = (values: RankingValue[]): { value: string; rank: number }[] => + values + .filter((v) => v.rank != null) + .sort((a, b) => (a.rank as number) - (b.rank as number)) + .map((v) => ({ value: v.value, rank: v.rank as number })); + +export const suggestionHintFor = (cell: ReviewCell): SuggestionHint | null => { + if (cell.source !== "suggestion" || !cell.provenance) return null; + return new SuggestionHint( + cell.provenance.suggestedValue, + cell.provenance.agent, + cell.provenance.score, + cell.question.type === "multi_label_selection" + ); +}; +``` + +Re-run: `npx vitest run v2/domain/entities/review --reporter=verbose` — expected PASS. + +- [ ] **Step 3: Add `review.*` i18n keys** + +In `extralit-frontend/translation/en.js`, add: + +```js + review: { + title: "Review", + submit: "Submit", + saveDraft: "Save draft", + discard: "Discard", + suggestion: "Suggestion", + response: "Response", + agent: "Agent", + score: "Score", + context: "Context", + notApplicable: "Not applicable in this schema version", + orphanedValues: "Values from removed questions (read-only, not resubmitted)", + loadError: "Could not load review for this reference.", + submitted: "Response submitted", + draftSaved: "Draft saved", + discarded: "Response discarded", + }, +``` + +- [ ] **Step 4: Write the failing form component test** + +Create `extralit-frontend/components/v2/review/ProjectionReviewForm.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { mount } from "@vue/test-utils"; +import ProjectionReviewForm from "./ProjectionReviewForm.vue"; +import { ReferenceReview, ReviewCell, ReviewRecord } from "~/v2/domain/entities/review/ReferenceReview"; +import { Question } from "~/v2/domain/entities/question/Question"; + +const textQuestion = new Question("q-size", "s-1", "size", "Sample size", null, "text", ["size"], {}, true); +const labelQuestion = new Question("q-label", "s-1", "label", "Label", null, "label_selection", ["label"], { + type: "label_selection", + options: [{ value: "a", text: "A", description: null }], +}, false); + +const makeReview = (cells: ReviewCell[], draft = null, orphaned: { name: string; value: unknown }[] = []) => + new ReferenceReview("10.1000/j.x", [new ReviewRecord("r-1", "s-1", "sample_size", cells, [], orphaned, draft)], 1); + +const stubs = { + // Leaves are exercised in their own suites; here we assert dispatch + emit shaping. + ContentEditableFeedbackTask: { template: "
", props: ["value"] }, + LabelSelectionComponent: { template: "
", props: ["modelValue"] }, + RatingMonoSelectionComponent: true, + DndSelectionComponent: true, + V2TableEditor: true, +}; + +describe("ProjectionReviewForm", () => { + it("renders a widget per question type and suggestion provenance", () => { + const review = makeReview([ + new ReviewCell(textQuestion, "12", "suggestion", { agent: "gpt", score: 0.9, suggestedValue: "12" }, false), + new ReviewCell(labelQuestion, null, null, null, false), + ]); + + const wrapper = mount(ProjectionReviewForm, { props: { review }, global: { stubs } }); + + expect(wrapper.find(".stub-text").exists()).toBe(true); + expect(wrapper.find(".stub-label").exists()).toBe(true); + expect(wrapper.text()).toContain("review.suggestion"); + expect(wrapper.text()).toContain("gpt"); + }); + + it("emits submit with (recordId, plain values) — page wraps them", async () => { + const review = makeReview([ + new ReviewCell(textQuestion, "12", "suggestion", { agent: "gpt", score: 0.9, suggestedValue: "12" }, false), + ]); + const wrapper = mount(ProjectionReviewForm, { props: { review }, global: { stubs } }); + + await wrapper.get("[data-test='submit-r-1']").trigger("click"); + + expect(wrapper.emitted("submit")).toEqual([["r-1", { size: "12" }]]); + }); + + it("marks not-applicable cells and excludes them from emitted values", async () => { + const review = makeReview([ + new ReviewCell(textQuestion, "12", "suggestion", null, false), + new ReviewCell(labelQuestion, "a", "suggestion", null, true), + ]); + const wrapper = mount(ProjectionReviewForm, { props: { review }, global: { stubs } }); + + expect(wrapper.text()).toContain("review.notApplicable"); + await wrapper.get("[data-test='save-draft-r-1']").trigger("click"); + expect(wrapper.emitted("save-draft")).toEqual([["r-1", { size: "12" }]]); + }); + + it("surfaces orphaned values read-only and never includes them in emits", async () => { + const review = makeReview( + [new ReviewCell(textQuestion, "12", null, null, false)], + null, + [{ name: "ghost", value: "zzz" }] + ); + const wrapper = mount(ProjectionReviewForm, { props: { review }, global: { stubs } }); + + expect(wrapper.text()).toContain("review.orphanedValues"); + expect(wrapper.text()).toContain("ghost"); + await wrapper.get("[data-test='submit-r-1']").trigger("click"); + expect(wrapper.emitted("submit")![0][1]).not.toHaveProperty("ghost"); + }); + + it("emits discard with the record id and renders submit errors passed back by the page", async () => { + const review = makeReview([new ReviewCell(textQuestion, null, null, null, false)]); + const wrapper = mount(ProjectionReviewForm, { + props: { review, submitErrors: { "r-1": ["missing value for required question: size"] } }, + global: { stubs }, + }); + + expect(wrapper.text()).toContain("missing value for required question: size"); + await wrapper.get("[data-test='discard-r-1']").trigger("click"); + expect(wrapper.emitted("discard")).toEqual([["r-1"]]); + }); +}); +``` + +Run: `npx vitest run components/v2/review --reporter=verbose` — expected FAIL (component missing). + +- [ ] **Step 5: Write the components** + +Create `extralit-frontend/components/v2/review/ReviewProvenance.vue`: + +```vue + + + + + +``` + +Create `extralit-frontend/components/v2/review/ReviewCellInput.vue` — the §6.1 dispatch (question.type → widget), keeping the leaves controlled: + +```vue + + + + + +``` + +Create `extralit-frontend/components/v2/review/ReviewRecordCard.vue`: + +```vue + + + + + +``` + +Note: `ReviewRecordCard` gets the pinned version's `columnsCache` (for table sub-column editors) from `record.columnsCache`, which Task 11's `ReviewRecord` already carries (last constructor param, defaulted to `[]` so this task's test fixtures can omit it). + +Create `extralit-frontend/components/v2/review/ProjectionReviewForm.vue`: + +```vue + + + +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `cd extralit-frontend && npx vitest run components/v2 v2 --reporter=verbose && npm run test` +Expected: all PASS (including updated Task 11 tests for the `columnsCache` field). + +- [ ] **Step 7: Commit** + +```bash +git add components/v2/review v2 translation/en.js +git commit -m "feat(v2-ui): pure ProjectionReviewForm with per-type widgets, provenance and orphan surfacing" +``` + +--- + +### Task 15: `/references/[...reference]` page + view-model + +**Files:** +- Create: `extralit-frontend/pages/references/[...reference].vue` +- Create: `extralit-frontend/pages/references/useReferenceReviewViewModel.ts` +- Test: `extralit-frontend/pages/references/useReferenceReviewViewModel.test.ts` + +**Interfaces:** +- Consumes: `GetReferenceReviewUseCase` (Task 11); `SubmitReferenceReviewUseCase` / `SaveReviewDraftUseCase` / `DiscardReviewUseCase` / `ReviewSubmitError` (Task 12); `useReferenceReviews` (Task 11); `ProjectionReviewForm` (Task 14); `useNotifications` (v1 services, allowed). +- Produces: route `/references/?workspace_id=`; `useReferenceReviewViewModel(reference: string, workspaceId: string): { review, isLoading, loadFailed, submitErrors, onSubmit, onSaveDraft, onDiscard }`. The page is deliberately the *first thin wrapper* around the form (route param in, composable + form, nothing else — spec §7). + +- [ ] **Step 1: Write the failing view-model test** + +Create `extralit-frontend/pages/references/useReferenceReviewViewModel.test.ts`: + +```ts +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; +import { useResolveMock } from "~/v1/di/__mocks__/useResolveMock"; +import { GetReferenceReviewUseCase } from "~/v2/domain/usecases/get-reference-review-use-case"; +import { SubmitReferenceReviewUseCase, ReviewSubmitError } from "~/v2/domain/usecases/submit-reference-review-use-case"; +import { SaveReviewDraftUseCase } from "~/v2/domain/usecases/save-review-draft-use-case"; +import { DiscardReviewUseCase } from "~/v2/domain/usecases/discard-review-use-case"; +import { ReferenceReview } from "~/v2/domain/entities/review/ReferenceReview"; +import { useReferenceReviewViewModel } from "./useReferenceReviewViewModel"; + +vi.mock("~/v1/infrastructure/services/useNotifications", () => ({ + useNotifications: () => ({ notify: vi.fn() }), +})); +vi.mock("~/v1/infrastructure/services/useTranslate", () => ({ + useTranslate: () => ({ t: (key: string) => key, tc: (key: string) => key }), +})); + +const REVIEW = new ReferenceReview("10.1000/j.x", [], 0); + +describe("useReferenceReviewViewModel", () => { + beforeEach(() => setActivePinia(createPinia())); + + it("loads the review on mount-equivalent call and exposes it", async () => { + const execute = vi.fn(async () => REVIEW); + useResolveMock(GetReferenceReviewUseCase, { execute }); + useResolveMock(SubmitReferenceReviewUseCase, { execute: vi.fn() }); + useResolveMock(SaveReviewDraftUseCase, { execute: vi.fn() }); + useResolveMock(DiscardReviewUseCase, { execute: vi.fn() }); + + const vm = useReferenceReviewViewModel("10.1000/j.x", "w-1"); + await vm.load(); + + expect(execute).toHaveBeenCalledWith("10.1000/j.x", "w-1"); + expect(vm.review.value).toBe(REVIEW); + }); + + it("collects normalized 422 messages per record on submit failure, then clears on success", async () => { + useResolveMock(GetReferenceReviewUseCase, { execute: vi.fn(async () => REVIEW) }); + const submit = vi + .fn() + .mockRejectedValueOnce(new ReviewSubmitError(["missing value for required question: size"], 422)) + .mockResolvedValueOnce({ id: "resp" }); + useResolveMock(SubmitReferenceReviewUseCase, { execute: submit }); + useResolveMock(SaveReviewDraftUseCase, { execute: vi.fn() }); + useResolveMock(DiscardReviewUseCase, { execute: vi.fn() }); + + const vm = useReferenceReviewViewModel("10.1000/j.x", "w-1"); + await vm.onSubmit("r-1", {}); + expect(vm.submitErrors.value["r-1"]).toEqual(["missing value for required question: size"]); + + await vm.onSubmit("r-1", { size: "12" }); + expect(vm.submitErrors.value["r-1"]).toBeUndefined(); + }); + + it("reloads the review after a successful submit so the projection flips to response", async () => { + const load = vi.fn(async () => REVIEW); + useResolveMock(GetReferenceReviewUseCase, { execute: load }); + useResolveMock(SubmitReferenceReviewUseCase, { execute: vi.fn(async () => ({ id: "resp" })) }); + useResolveMock(SaveReviewDraftUseCase, { execute: vi.fn() }); + useResolveMock(DiscardReviewUseCase, { execute: vi.fn() }); + + const vm = useReferenceReviewViewModel("10.1000/j.x", "w-1"); + await vm.load(); + await vm.onSubmit("r-1", { size: "12" }); + + expect(load).toHaveBeenCalledTimes(2); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd extralit-frontend && npx vitest run pages/references --reporter=verbose` +Expected: FAIL. + +- [ ] **Step 3: Write the view-model and page** + +Create `extralit-frontend/pages/references/useReferenceReviewViewModel.ts`: + +```ts +import { ref } from "vue"; +import { useResolve } from "ts-injecty"; +import { GetReferenceReviewUseCase } from "~/v2/domain/usecases/get-reference-review-use-case"; +import { SubmitReferenceReviewUseCase, ReviewSubmitError } from "~/v2/domain/usecases/submit-reference-review-use-case"; +import { SaveReviewDraftUseCase } from "~/v2/domain/usecases/save-review-draft-use-case"; +import { DiscardReviewUseCase } from "~/v2/domain/usecases/discard-review-use-case"; +import { ReferenceReview } from "~/v2/domain/entities/review/ReferenceReview"; +import { useNotifications } from "~/v1/infrastructure/services/useNotifications"; +import { useTranslate } from "~/v1/infrastructure/services/useTranslate"; + +export const useReferenceReviewViewModel = (reference: string, workspaceId: string) => { + const getReviewUseCase = useResolve(GetReferenceReviewUseCase); + const submitUseCase = useResolve(SubmitReferenceReviewUseCase); + const saveDraftUseCase = useResolve(SaveReviewDraftUseCase); + const discardUseCase = useResolve(DiscardReviewUseCase); + const notifications = useNotifications(); + const { t } = useTranslate(); + + const review = ref(null); + const isLoading = ref(false); + const loadFailed = ref(false); + const submitErrors = ref>({}); + + const load = async () => { + isLoading.value = true; + loadFailed.value = false; + try { + review.value = await getReviewUseCase.execute(reference, workspaceId); + } catch { + loadFailed.value = true; + } finally { + isLoading.value = false; + } + }; + + const runAction = async (recordId: string, action: () => Promise, successKey: string) => { + try { + await action(); + const { [recordId]: _cleared, ...rest } = submitErrors.value; + submitErrors.value = rest; + notifications.notify({ message: t(successKey), type: "success" }); + await load(); // re-read: projection source flips response/suggestion server-side + } catch (error) { + if (error instanceof ReviewSubmitError) { + submitErrors.value = { ...submitErrors.value, [recordId]: error.messages }; + } else { + throw error; + } + } + }; + + const onSubmit = (recordId: string, values: Record) => + runAction(recordId, () => submitUseCase.execute(recordId, values), "review.submitted"); + const onSaveDraft = (recordId: string, values: Record) => + runAction(recordId, () => saveDraftUseCase.execute(recordId, values), "review.draftSaved"); + const onDiscard = (recordId: string) => + runAction(recordId, () => discardUseCase.execute(recordId), "review.discarded"); + + return { review, isLoading, loadFailed, submitErrors, load, onSubmit, onSaveDraft, onDiscard }; +}; +``` + +Create `extralit-frontend/pages/references/[...reference].vue`: + +```vue + + + + + +``` + +- [ ] **Step 4: Run tests and full suite** + +Run: `cd extralit-frontend && npx vitest run pages/references --reporter=verbose && npm run test` +Expected: green. + +- [ ] **Step 5: Live smoke against the local stack (optional but recommended)** + +With the server stack up (`docker-compose up -d` at repo root; `cd extralit-server && uv run python -m extralit_server server-dev`), run `npm run dev`, seed a schema/records via the SDK or the Task 16 seed script, and open `/references/?workspace_id=`. Verify the form renders and a submit round-trips. + +- [ ] **Step 6: Commit** + +```bash +git add pages/references +git commit -m "feat(v2-ui): reference review page wrapping ProjectionReviewForm via composable" +``` + +--- + +### Task 16: e2e infrastructure (remote-chromium project, seed script) + scenarios 1–2 + +**Files:** +- Modify: `extralit-frontend/playwright.config.ts` (v2 project; isolate old projects from `e2e/v2`) +- Create: `extralit-frontend/e2e/v2/fixtures.ts` +- Create: `extralit-frontend/e2e/v2/seed/seed_v2_e2e.py` +- Create: `extralit-frontend/e2e/v2/auth-smoke.spec.ts` (scenario 1) +- Create: `extralit-frontend/e2e/v2/slashed-reference.spec.ts` (scenario 2) +- Create: `extralit-frontend/e2e/v2/README.md` (how to run on this host) + +**Interfaces:** +- Consumes: the real backend stack (no network mocks — seams A/B are the point), the seeded data contract below, Tasks 6–15 UI. +- Produces: + - `test`/`expect` re-exports from `e2e/v2/fixtures.ts` with a worker-scoped `browser` fixture: `chromium.connectOverCDP(process.env.E2E_CDP_URL)` when set, plain `chromium.launch()` otherwise (CI). + - Seed contract (JSON written to `e2e/v2/seed/seed-output.json`): `{ workspaceId, schemaId, schemaName, reference, recordId, questions: { size: {id, name}, label: {id, name} } }` with `reference = "10.1000/j.e2e-v2"` (contains a slash by design). + - Env vars: `E2E_BASE_URL` (frontend URL reachable FROM the remote browser, e.g. `http://192.168.1.79:3000`), `E2E_CDP_URL` (remote chromium CDP endpoint, e.g. `http://ccui:9222`), `E2E_API_URL` (server, default `http://localhost:6900`), `E2E_USERNAME`/`E2E_PASSWORD` (owner credentials). + +Host context (from repo memory/CLAUDE.md): local chromium cannot launch on this Orin host (missing OS libs, no sudo) — the CDP path is the local runway; plain launch is for CI. The stale Argilla `e2e/` specs are not a gate; only `e2e/v2/` is. + +- [ ] **Step 1: Playwright config — dedicated v2 project** + +In `extralit-frontend/playwright.config.ts`: + +1. Add `testIgnore: "v2/**"` to each of the three existing project entries, e.g.: + +```ts + { name: "chromium", testIgnore: "v2/**", use: { ...devices["Desktop Chrome"] } }, + { name: "firefox", testIgnore: "v2/**", use: { ...devices["Desktop Firefox"] } }, + { name: "webkit", testIgnore: "v2/**", use: { ...devices["Desktop Safari"] } }, +``` + +2. Append the v2 project: + +```ts + { + name: "v2", + testMatch: "v2/**/*.spec.ts", + retries: 0, // real backend: retries mask seeding/state bugs + use: { + ...devices["Desktop Chrome"], + baseURL: process.env.E2E_BASE_URL ?? process.env.BASE_URL ?? "http://localhost:3000", + }, + }, +``` + +3. Leave `webServer` as is (`reuseExistingServer: !process.env.CI` — locally you start `npm run dev -- --host` yourself so the remote browser can reach it). + +- [ ] **Step 2: CDP browser fixture** + +Create `extralit-frontend/e2e/v2/fixtures.ts`: + +```ts +import { test as base, chromium, type Browser } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +export interface SeedOutput { + workspaceId: string; + schemaId: string; + schemaName: string; + reference: string; + recordId: string; + questions: Record; +} + +export const loadSeed = (): SeedOutput => + JSON.parse(readFileSync(join(__dirname, "seed", "seed-output.json"), "utf-8")); + +export const credentials = () => ({ + username: process.env.E2E_USERNAME ?? "extralit", + password: process.env.E2E_PASSWORD ?? "12345678", +}); + +// Local chromium cannot launch on the Orin dev host — connect to the remote ccui +// chromium over CDP when E2E_CDP_URL is set; fall back to a plain launch (CI). +export const test = base.extend({ + browser: [ + async ({}, use) => { + const cdpUrl = process.env.E2E_CDP_URL; + const browser = cdpUrl ? await chromium.connectOverCDP(cdpUrl) : await chromium.launch(); + await use(browser); + await browser.close(); + }, + { scope: "worker" }, + ], +}); + +export const expect = test.expect; + +// Real-backend sign-in through the actual UI: this is seam A — the first bearer-token +// client of /api/v2. No route mocking anywhere in e2e/v2. +export const signIn = async (page: import("@playwright/test").Page) => { + const { username, password } = credentials(); + await page.goto("/sign-in"); + await page.getByLabel("Username").fill(username); + await page.getByLabel("Password").fill(password); + await page.getByRole("button", { name: "Sign in", exact: true }).click(); + await page.waitForURL((url) => !url.pathname.startsWith("/sign-in")); +}; +``` + +- [ ] **Step 3: Seed script** + +Create `extralit-frontend/e2e/v2/seed/seed_v2_e2e.py` (run with the server project's env so `pandera` matches the server's version — the schema `body` must be a Pandera `DataFrameSchema.to_json()` string, exactly as the server's own tests build it): + +```python +"""Seed deterministic v2 fixtures for the frontend e2e suite. + +Usage (from extralit-frontend/): + uv run --project ../extralit-server python e2e/v2/seed/seed_v2_e2e.py \ + --api-url http://localhost:6900 --username extralit --password 12345678 + +Writes e2e/v2/seed/seed-output.json. Idempotent: deletes and recreates the e2e schema. +""" + +import argparse +import json +from pathlib import Path + +import httpx +import pandera.pandas as pa + +SCHEMA_NAME = "e2e_v2_slice" +REFERENCE = "10.1000/j.e2e-v2" # slash on purpose: seam B +WORKSPACE_NAME = "e2e-v2" + +BODY = pa.DataFrameSchema( + columns={ + "size": pa.Column(pa.String, nullable=True), + "label": pa.Column(pa.String, nullable=True), + "country": pa.Column(pa.String, nullable=True), + } +).to_json() + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--api-url", default="http://localhost:6900") + parser.add_argument("--username", default="extralit") + parser.add_argument("--password", default="12345678") + args = parser.parse_args() + + with httpx.Client(base_url=args.api_url, timeout=30) as client: + token = client.post( + "/api/v2/token", data={"username": args.username, "password": args.password} + ).raise_for_status().json()["access_token"] + client.headers["Authorization"] = f"Bearer {token}" + + # Workspace (v1 API): reuse if it exists. + workspaces = client.get("/api/v1/me/workspaces").raise_for_status().json()["items"] + workspace = next((w for w in workspaces if w["name"] == WORKSPACE_NAME), None) + if workspace is None: + workspace = client.post("/api/v1/workspaces", json={"name": WORKSPACE_NAME}).raise_for_status().json() + + # Schema: recreate for determinism. + schemas = client.get("/api/v2/schemas", params={"workspace_id": workspace["id"]}).raise_for_status().json()["items"] + for schema in schemas: + if schema["name"] == SCHEMA_NAME: + client.delete(f"/api/v2/schemas/{schema['id']}").raise_for_status() + schema = client.post( + "/api/v2/schemas", json={"name": SCHEMA_NAME, "workspace_id": workspace["id"]} + ).raise_for_status().json() + + client.post(f"/api/v2/schemas/{schema['id']}/versions", json={"body": BODY}).raise_for_status() + + questions = {} + for name, qtype, settings in [ + ("size", "text", {}), + ( + "label", + "label_selection", + { + "type": "label_selection", + "options": [ + {"value": "intervention", "text": "Intervention", "description": None}, + {"value": "control", "text": "Control", "description": None}, + ], + }, + ), + ]: + question = client.post( + f"/api/v2/schemas/{schema['id']}/questions", + json={"name": name, "title": name.title(), "type": qtype, "columns": [name], "settings": settings, "required": name == "size"}, + ).raise_for_status().json() + questions[name] = {"id": question["id"], "name": question["name"]} + + records = client.post( + f"/api/v2/schemas/{schema['id']}/records:bulk-upsert", + json={"items": [{"fields": {"size": "120", "label": "control", "country": "KE"}, "reference": REFERENCE}]}, + ).raise_for_status().json()["items"] + record = records[0] + + client.put( + f"/api/v2/records/{record['id']}/suggestions", + json={"question_id": questions["size"]["id"], "value": "120", "score": 0.87, "agent": "e2e-seeder"}, + ).raise_for_status() + + # Fresh index so the search scenario has something to find. + client.post(f"/api/v2/schemas/{schema['id']}:rebuild-index").raise_for_status() + + output = { + "workspaceId": workspace["id"], + "schemaId": schema["id"], + "schemaName": SCHEMA_NAME, + "reference": REFERENCE, + "recordId": record["id"], + "questions": questions, + } + out_path = Path(__file__).parent / "seed-output.json" + out_path.write_text(json.dumps(output, indent=2)) + print(json.dumps(output, indent=2)) + + +if __name__ == "__main__": + main() +``` + +Add npm script in `extralit-frontend/package.json`: + +```json +"e2e:v2:seed": "uv run --project ../extralit-server python e2e/v2/seed/seed_v2_e2e.py", +"e2e:v2": "playwright test --project=v2" +``` + +Add `e2e/v2/seed/seed-output.json` to `extralit-frontend/.gitignore`. + +- [ ] **Step 4: Scenario 1 — auth + smoke** + +Create `extralit-frontend/e2e/v2/auth-smoke.spec.ts`: + +```ts +import { expect, loadSeed, signIn, test } from "./fixtures"; + +// Seam A (spec §10.1-A): first bearer-token client of /api/v2 — no server test sends +// Authorization: Bearer or a CORS Origin to a v2 route. Nothing here is mocked. +test("signs in with a bearer token, lists schemas, opens records", async ({ page }) => { + const seed = loadSeed(); + + const schemasRequest = page.waitForResponse( + (r) => r.url().includes("/api/v2/schemas") && r.request().method() === "GET" + ); + await signIn(page); + await page.goto("/schemas"); + + const schemasResponse = await schemasRequest; + expect(schemasResponse.status()).toBe(200); + expect(schemasResponse.request().headers()["authorization"]).toMatch(/^Bearer /); + + await expect(page.getByText(seed.schemaName)).toBeVisible(); + + await page.getByText(seed.schemaName).click(); + await expect(page.getByText(seed.reference)).toBeVisible(); +}); +``` + +Note: the schema list loads for the *selected workspace* — if the seeded workspace isn't the first, switch to it via the workspace selector before asserting (check the home header UI for the selector; add the interaction here once visible). + +- [ ] **Step 5: Scenario 2 — slashed-DOI reference** + +Create `extralit-frontend/e2e/v2/slashed-reference.spec.ts`: + +```ts +import { expect, loadSeed, signIn, test } from "./fixtures"; + +// Seam B (spec §10.1-B): %2F-encoded DOI through Nuxt devProxy + uvicorn, untested server-side +// for the projection route. Assert both v2 reference endpoints round-trip. +test("opens a reference containing a slash via the encoded URL", async ({ page }) => { + const seed = loadSeed(); + await signIn(page); + + const projectionRequest = page.waitForResponse( + (r) => r.url().includes("/api/v2/projection/references/") && r.request().method() === "GET" + ); + const recordsRequest = page.waitForResponse( + (r) => r.url().includes(`/api/v2/schemas/${seed.schemaId}/records`) && r.request().method() === "GET" + ); + + await page.goto(`/references/${encodeURIComponent(seed.reference)}?workspace_id=${seed.workspaceId}`); + + expect((await projectionRequest).status()).toBe(200); + expect((await recordsRequest).status()).toBe(200); + + await expect(page.getByText(seed.reference)).toBeVisible(); + await expect(page.locator("[data-question='size']")).toBeVisible(); +}); +``` + +- [ ] **Step 6: README + run** + +Create `extralit-frontend/e2e/v2/README.md`: + +```markdown +# v2 e2e suite (real backend, remote chromium) + +Prereqs: full local stack up (`docker-compose up -d`, server on :6900), then: + +1. Seed: `npm run e2e:v2:seed` +2. Dev server reachable from the browser container: `npm run dev -- --host` +3. Run: + E2E_CDP_URL=http://ccui:9222 \ + E2E_BASE_URL=http://:3000 \ + npm run e2e:v2 + +Without `E2E_CDP_URL` (e.g. CI) a local chromium is launched. No network mocking: +these specs gate real auth (bearer on /api/v2), slashed-DOI encoding, the +suggestion→response loop, drafts and search freshness. The legacy Argilla specs +under e2e/* are not a gate for v2 work. +``` + +Run scenarios 1–2 per the README. Expected: both green against the live stack. Debug tips: `page.waitForResponse` timeouts usually mean the devProxy or workspace selection, not the assertion. + +- [ ] **Step 7: Commit** + +```bash +git add e2e/v2 playwright.config.ts package.json .gitignore +git commit -m "test(v2-ui): e2e infra (CDP remote chromium, API seeding) + auth and slashed-DOI scenarios" +``` + +--- + +### Task 17: e2e scenarios 3–5 (review loop, draft lifecycle, search round-trip) + +**Files:** +- Create: `extralit-frontend/e2e/v2/review-loop.spec.ts` (scenario 3) +- Create: `extralit-frontend/e2e/v2/draft-lifecycle.spec.ts` (scenario 4) +- Create: `extralit-frontend/e2e/v2/search-roundtrip.spec.ts` (scenario 5) + +**Interfaces:** +- Consumes: Task 16 fixtures/seed (`loadSeed`, `signIn`, seeded suggestion on question `size` with agent `e2e-seeder`), Task 14 form DOM contract (`[data-question]`, `[data-test='submit-']`, provenance badges rendering `review.suggestion`/`review.response` copy — i.e. "Suggestion"/"Response" in English). +- Produces: the remaining slice-gating scenarios (spec §10.2 items 3–5). Scenarios 6–8 are follow-ups, out of this plan. + +**IMPORTANT — verify seam C server behavior FIRST (spec §10.1-C):** the draft/discard response lifecycle has zero server tests. Before writing scenario 4's assertions, run the three `curl`s below against the live stack and confirm: (a) a `draft` response does NOT flip the projection cell (`source` stays `suggestion`), (b) `discarded` reverts a previously submitted cell to the suggestion. If either fails, STOP and report upstream — file it in the spec ledger; do not code the frontend around broken semantics. + +```bash +TOKEN=$(curl -s -X POST http://localhost:6900/api/v2/token -d "username=extralit&password=12345678" | jq -r .access_token) +RID=$(jq -r .recordId e2e/v2/seed/seed-output.json) +curl -s -X PUT "http://localhost:6900/api/v2/records/$RID/responses" -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" -d '{"values": {"size": {"value": "999"}}, "status": "draft"}' +curl -s "http://localhost:6900/api/v2/projection/references/10.1000%2Fj.e2e-v2?workspace_id=$(jq -r .workspaceId e2e/v2/seed/seed-output.json)" \ + -H "Authorization: Bearer $TOKEN" | jq '.records[0].cells[] | select(.question_name=="size")' +# expect: value "120", source "suggestion" (draft must NOT project) +``` + +- [ ] **Step 1: Scenario 3 — suggestion→response conversion loop** + +Create `extralit-frontend/e2e/v2/review-loop.spec.ts`: + +```ts +import { expect, loadSeed, signIn, test } from "./fixtures"; + +// The core product loop (spec §10.2-3): suggestion shown with provenance → edit → submit → +// projection re-read flips source to response. Never chained over HTTP in the server suites. +test("converts a suggestion into a submitted response", async ({ page }) => { + const seed = loadSeed(); + await signIn(page); + await page.goto(`/references/${encodeURIComponent(seed.reference)}?workspace_id=${seed.workspaceId}`); + + const sizeCell = page.locator("[data-question='size']"); + await expect(sizeCell).toBeVisible(); + await expect(sizeCell.getByText("Suggestion")).toBeVisible(); + await expect(sizeCell.getByText("e2e-seeder")).toBeVisible(); + + // Edit the text answer (ContentEditableFeedbackTask renders a contenteditable paragraph). + const editor = sizeCell.locator("[contenteditable]"); + await editor.click(); + await editor.fill("135"); + + const putResponse = page.waitForResponse( + (r) => r.url().includes(`/api/v2/records/${seed.recordId}/responses`) && r.request().method() === "PUT" + ); + await page.locator(`[data-test='submit-${seed.recordId}']`).click(); + expect((await putResponse).status()).toBe(200); + + // Reload: the projection must now resolve from the submitted response. + await page.reload(); + await expect(sizeCell.getByText("Response")).toBeVisible(); + await expect(sizeCell.getByText("Suggestion")).not.toBeVisible(); +}); +``` + +(If `contenteditable.fill` is flaky over CDP, use `editor.click()` + `page.keyboard` select-all/type. Reset state for re-runs by re-running the seed script — it recreates the schema.) + +- [ ] **Step 2: Scenario 4 — draft lifecycle** + +Create `extralit-frontend/e2e/v2/draft-lifecycle.spec.ts`: + +```ts +import { expect, loadSeed, signIn, test } from "./fixtures"; + +// Seam C (spec §10.1-C): drafts have ZERO server-side tests. This spec is the gate: +// a draft restores into the form on reload while the projection still shows the suggestion; +// submitting then flips the projection to response. +test("draft persists in the form without touching the projection, then submits", async ({ page }) => { + const seed = loadSeed(); + await signIn(page); + await page.goto(`/references/${encodeURIComponent(seed.reference)}?workspace_id=${seed.workspaceId}`); + + const sizeCell = page.locator("[data-question='size']"); + const editor = sizeCell.locator("[contenteditable]"); + await editor.click(); + await editor.fill("777"); + + const draftPut = page.waitForResponse( + (r) => r.url().includes(`/records/${seed.recordId}/responses`) && r.request().method() === "PUT" + ); + await page.locator(`[data-test='save-draft-${seed.recordId}']`).click(); + expect((await draftPut).status()).toBe(200); + + await page.reload(); + // Form restores the draft value... + await expect(sizeCell.locator("[contenteditable]")).toHaveText("777"); + // ...but the projection still resolves the suggestion (draft must not project). + await expect(sizeCell.getByText("Suggestion")).toBeVisible(); + + await page.locator(`[data-test='submit-${seed.recordId}']`).click(); + await page.reload(); + await expect(sizeCell.getByText("Response")).toBeVisible(); +}); +``` + +- [ ] **Step 3: Scenario 5 — search round-trip** + +Create `extralit-frontend/e2e/v2/search-roundtrip.spec.ts`: + +```ts +import { expect, loadSeed, signIn, test } from "./fixtures"; + +// Seam D (spec §10.1-D): first real write→search freshness check anywhere. The index is +// best-effort/eventually consistent — poll with expect.toPass instead of asserting once. +test("FTS finds the seeded record; filters and empty results render gracefully", async ({ page }) => { + const seed = loadSeed(); + await signIn(page); + await page.goto(`/schemas/${seed.schemaId}`); + await expect(page.getByText(seed.reference)).toBeVisible(); + + const searchBox = page.getByPlaceholder("Search records…"); + + await expect(async () => { + await searchBox.fill("control"); + await searchBox.press("Enter"); + await expect(page.getByText(seed.reference)).toBeVisible({ timeout: 2_000 }); + }).toPass({ timeout: 30_000 }); // eventual consistency window + + // Filtered search: status filter travels through the :search body. + await page.locator("select").selectOption("pending"); + await expect(page.getByText(seed.reference)).toBeVisible(); + + // Graceful empty state — copy must not claim "0 records exist" (total is approximate). + await searchBox.fill("zzz-no-such-token-zzz"); + await searchBox.press("Enter"); + await expect(page.getByText("No records match this search.")).toBeVisible(); +}); +``` + +- [ ] **Step 4: Run the full v2 e2e suite** + +```bash +cd extralit-frontend +npm run e2e:v2:seed +E2E_CDP_URL=http://ccui:9222 E2E_BASE_URL=http://:3000 npm run e2e:v2 +``` + +Expected: 5 specs green. Scenario 4's failure mode is informative, not just red: if the draft DOES project, that's the seam-C server bug — report upstream (spec ledger §10.3 pattern) with the curl reproduction from the task preamble. + +- [ ] **Step 5: Commit** + +```bash +git add e2e/v2 +git commit -m "test(v2-ui): e2e review loop, draft lifecycle and search round-trip scenarios" +``` + +--- + +## Final verification (after all tasks) + +- [ ] `cd extralit-server && uv run pytest tests/unit --disable-warnings` — green. +- [ ] `cd extralit-frontend && npm run gen:api && git diff --exit-code -- v2/infrastructure/api` — no drift. +- [ ] `cd extralit-frontend && npm run test && npm run lint && npx nuxi typecheck` — green (typecheck may surface pre-existing v1 issues; only new `v2/`/`components/v2` errors block). +- [ ] Full e2e: seed + scenarios 1–5 green against the live stack. +- [ ] Boundary audit: `grep -rn "from \"[~@]/v1" extralit-frontend/v2/ | grep -v "store/create\|infrastructure/services"` → empty; `grep -rn "v2/" extralit-frontend/v1/` → empty. +- [ ] Use superpowers:finishing-a-development-branch — PR against `develop`. + +## Deferred / ledger (do not build now) + +- Scenarios 6–8 (multi-annotator isolation, old-version rendering e2e, required-422 rendering e2e) — follow the slice (spec §10.2). +- Server-side projection payload enrichment if the 5-endpoint `ReferenceReview` assembly proves chatty (spec §7 ledger). +- Column filters on the schema detail page derived from `columns_cache` (only `status` ships now). +- Markdown/table sub-modes of the text widget; span questions; schema authoring UI; Queue UI (Phase 5); v1 retirements (Phase 6). diff --git a/docs/superpowers/plans/2026-07-13-sdk-v2-vertical-slice.md b/docs/superpowers/plans/2026-07-13-sdk-v2-vertical-slice.md new file mode 100644 index 000000000..686cb4201 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-sdk-v2-vertical-slice.md @@ -0,0 +1,3057 @@ +# Python SDK v2 Vertical Slice Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the parallel `extralit.v2` SDK package (generated DTOs from the server's `openapi-dump` snapshot, async-native transport + sync facade, five resources for the extraction loop) and top-level agentic CLI verbs (JSON-first) that replace the v1 `schemas` subcommand. + +**Architecture:** A new `src/extralit/v2/` package behind a hard import wall (v2 imports nothing from v1 except the credentials helper; only `cli/app.py` — the composition root — imports v2). Wire types are generated from a committed OpenAPI snapshot; hand-written resources form the anti-corruption layer where all wire quirks live; a background-thread event-loop portal gives every async method a mechanical sync mirror. + +**Tech Stack:** httpx (AsyncClient), pydantic v2, datamodel-code-generator (dev), typer, pytest + pytest-asyncio (strict mode) + pytest-httpx. + +**Spec:** `docs/superpowers/specs/2026-07-13-sdk-v2-redesign-design.md` + +## Global Constraints + +- All work happens in `extralit/` (the SDK dir of the monorepo). Run commands from `/home/jonny/Projects/Extralit/extralit/extralit/` unless stated otherwise. +- Python floor is `>=3.9.2`: in all v2 source, use `Optional[X]` — never `X | None` — in annotations that pydantic evaluates. Builtin generics (`list[str]`, `dict[str, Any]`) are fine. Put `from __future__ import annotations` at the top of non-model modules only. +- Package management via `uv` only (`uv add`, `uv run pytest`, …). Ruff line length is 120. +- Import wall: files under `src/extralit/v2/` may import stdlib, `httpx`, `pydantic`, `typer`, `rich`, other `extralit.v2.*` modules, and exactly one v1 module: `extralit.client.login` (credentials). No other `extralit.*` import. Outside `v2/`, only `src/extralit/cli/app.py` may import `extralit.v2.*`. +- All wire payloads are typed from `src/extralit/v2/_api/_generated.py`. Never hand-write a wire shape. +- Server caps (mirror, don't re-invent): bulk-upsert ≤ 500 items/request, delete ≤ 100 ids/request, search/list `limit` 1–1000 (default 50). +- Auth endpoints: `POST /api/v2/token` (form `username`/`password`) and `POST /api/v2/token/refresh` (JSON `{"refresh_token": …}`) both return **201** with `{"access_token", "refresh_token"}`. API-key header is `X-Extralit-Api-Key`. +- v2 async tests: pytest-asyncio is in strict mode (no `asyncio_mode` configured) — every async test file needs `pytestmark = pytest.mark.asyncio`. +- Tests live under `tests/unit/v2/`. Commit after every task (pre-commit runs ruff). +- Search/list `total` is approximate (stale index ids skipped; FTS saturates ~10k) — document, never assert exactness in ergonomics. + +--- + +### Task 1: Contract layer — snapshot, codegen, drift gates + +**Files:** +- Create: `src/extralit/v2/__init__.py` (placeholder), `src/extralit/v2/_api/__init__.py`, `src/extralit/v2/_api/openapi.json` (generated), `src/extralit/v2/_api/_generated.py` (generated), `tests/unit/v2/__init__.py`, `tests/unit/v2/test_contract.py` +- Modify: `pyproject.toml` (dev dep + ruff exclude) + +**Interfaces:** +- Produces: `extralit.v2._api._generated` exporting pydantic v2 models named by the server's OpenAPI components: `SchemaRead`, `Schemas`, `SchemaCreate`, `SchemaUpdate`, `SchemaVersionCreate`, `SchemaVersionRead`, `RecordUpsert`, `RecordsBulkUpsert`, `RecordRead`, `Records`, `RecordFilter`, `RecordSearchQuery`, `ReferenceGroup`, `ReferenceView`, `QuestionCreate`, `QuestionUpdate`, `QuestionRead`, `Questions`, `SuggestionUpsert`, `SuggestionRead`, `Suggestions`, `ResponseUpsert`, `ResponseRead`, `ProjectionCell`, `ProjectionRecord`, `ProjectionView`, and enums `SchemaStatus`, `V2RecordStatus`, `QuestionType`, `SuggestionType`, `ResponseStatus`. + +- [ ] **Step 1: Add the codegen dev dependency** + +```bash +cd /home/jonny/Projects/Extralit/extralit/extralit +uv add --dev "datamodel-code-generator>=0.26" +``` + +- [ ] **Step 2: Dump the OpenAPI snapshot from the server tree** + +```bash +mkdir -p src/extralit/v2/_api +cd ../extralit-server +uv run python -m extralit_server openapi-dump --output ../extralit/src/extralit/v2/_api/openapi.json +cd ../extralit +python -c "import json; json.load(open('src/extralit/v2/_api/openapi.json'))" # sanity: valid JSON +``` + +- [ ] **Step 3: Generate the DTO module** + +```bash +uv run datamodel-codegen \ + --input src/extralit/v2/_api/openapi.json \ + --input-file-type openapi \ + --output src/extralit/v2/_api/_generated.py \ + --output-model-type pydantic_v2.BaseModel \ + --target-python-version 3.9 \ + --use-double-quotes \ + --disable-timestamp +``` + +`--disable-timestamp` is load-bearing: the no-drift gate diffs regenerated output byte-for-byte. + +- [ ] **Step 4: Exclude the generated file from ruff** + +In `pyproject.toml`, extend the `[tool.ruff]` section: + +```toml +[tool.ruff] +line-length = 120 +extend-exclude = ["src/extralit/v2/_api/_generated.py"] +``` + +Create the two package inits (empty for now): + +```bash +touch src/extralit/v2/__init__.py src/extralit/v2/_api/__init__.py tests/unit/v2/__init__.py +``` + +- [ ] **Step 5: Write the contract tests** + +`tests/unit/v2/test_contract.py`: + +```python +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +SDK_ROOT = Path(__file__).parents[3] +API_DIR = SDK_ROOT / "src" / "extralit" / "v2" / "_api" +SNAPSHOT = API_DIR / "openapi.json" +GENERATED = API_DIR / "_generated.py" +SERVER_DIR = SDK_ROOT.parent / "extralit-server" + +EXPECTED_MODELS = [ + "SchemaRead", "Schemas", "SchemaCreate", "SchemaUpdate", "SchemaVersionCreate", "SchemaVersionRead", + "RecordUpsert", "RecordsBulkUpsert", "RecordRead", "Records", "RecordFilter", "RecordSearchQuery", + "ReferenceGroup", "ReferenceView", + "QuestionCreate", "QuestionUpdate", "QuestionRead", "Questions", + "SuggestionUpsert", "SuggestionRead", "Suggestions", "ResponseUpsert", "ResponseRead", + "ProjectionCell", "ProjectionRecord", "ProjectionView", + "SchemaStatus", "V2RecordStatus", "QuestionType", "SuggestionType", "ResponseStatus", +] + + +def test_generated_models_importable(): + import extralit.v2._api._generated as gen + + missing = [name for name in EXPECTED_MODELS if not hasattr(gen, name)] + assert not missing, f"generated module lacks: {missing}" + + +def test_generated_matches_snapshot(tmp_path): + """No-drift gate: regenerating from the committed snapshot must be byte-identical.""" + out = tmp_path / "regen.py" + subprocess.run( + [ + sys.executable, "-m", "datamodel_code_generator", + "--input", str(SNAPSHOT), "--input-file-type", "openapi", + "--output", str(out), "--output-model-type", "pydantic_v2.BaseModel", + "--target-python-version", "3.9", "--use-double-quotes", "--disable-timestamp", + ], + check=True, capture_output=True, + ) + assert out.read_text() == GENERATED.read_text(), ( + "src/extralit/v2/_api/_generated.py drifted from openapi.json — rerun datamodel-codegen (see plan Task 1 Step 3)" + ) + + +@pytest.mark.slow +@pytest.mark.skipif(not SERVER_DIR.exists(), reason="server tree not present") +def test_snapshot_matches_server(): + """Snapshot-vs-server gate: committed snapshot must equal a fresh openapi-dump.""" + proc = subprocess.run( + ["uv", "run", "python", "-m", "extralit_server", "openapi-dump"], + cwd=SERVER_DIR, check=True, capture_output=True, text=True, + ) + assert json.loads(proc.stdout) == json.loads(SNAPSHOT.read_text()), ( + "openapi.json snapshot drifted from the server — re-dump it (see plan Task 1 Step 2)" + ) +``` + +- [ ] **Step 6: Run the tests** + +```bash +uv run pytest tests/unit/v2/test_contract.py -v --disable-warnings +``` + +Expected: `test_generated_models_importable` and `test_generated_matches_snapshot` PASS; `test_snapshot_matches_server` deselected (slow) — run it once explicitly with `--runslow` and confirm PASS. If `test_generated_models_importable` fails on a name, inspect `_generated.py` for the actual component name and update `EXPECTED_MODELS` *and* note the real name for later tasks (later tasks import these names). + +- [ ] **Step 7: Commit** + +```bash +git add pyproject.toml uv.lock src/extralit/v2 tests/unit/v2 +git commit -m "feat(sdk-v2): committed OpenAPI snapshot + generated DTOs with drift gates" +``` + +--- + +### Task 2: Error hierarchy and 422 normalizer + +**Files:** +- Create: `src/extralit/v2/_api/_errors.py`, `tests/unit/v2/test_errors.py` + +**Interfaces:** +- Produces: + - `class V2APIError(Exception)` — attrs `.status_code: int`, `.detail: Any` + - `class AuthError(V2APIError)`, `class NotFoundError(V2APIError)` + - `class ValidationError(V2APIError)` — extra attr `.errors: list[dict]` (each `{"loc": list, "msg": str}`) + - `def error_from_response(status_code: int, body: Any) -> V2APIError` + - `def normalize_validation_detail(detail: Any) -> list[dict]` + +- [ ] **Step 1: Write the failing tests** + +`tests/unit/v2/test_errors.py`: + +```python +from extralit.v2._api._errors import ( + AuthError, + NotFoundError, + V2APIError, + ValidationError, + error_from_response, + normalize_validation_detail, +) + + +def test_normalizes_string_detail(): + assert normalize_validation_detail("boom") == [{"loc": [], "msg": "boom"}] + + +def test_normalizes_fastapi_list_detail(): + detail = [{"loc": ["body", "items", 0, "reference"], "msg": "field required", "type": "missing"}] + assert normalize_validation_detail(detail) == [ + {"loc": ["body", "items", 0, "reference"], "msg": "field required"} + ] + + +def test_normalizes_none_and_junk(): + assert normalize_validation_detail(None) == [] + assert normalize_validation_detail({"weird": 1}) == [{"loc": [], "msg": "{'weird': 1}"}] + + +def test_error_from_response_maps_statuses(): + assert isinstance(error_from_response(401, {"detail": "nope"}), AuthError) + assert isinstance(error_from_response(403, {"detail": "nope"}), AuthError) + assert isinstance(error_from_response(404, {"detail": "gone"}), NotFoundError) + err = error_from_response(422, {"detail": "bad value"}) + assert isinstance(err, ValidationError) + assert err.errors == [{"loc": [], "msg": "bad value"}] + other = error_from_response(500, {"detail": "kaboom"}) + assert type(other) is V2APIError + assert other.status_code == 500 and other.detail == "kaboom" + + +def test_error_from_response_non_dict_body(): + err = error_from_response(502, "bad gateway") + assert err.detail == "bad gateway" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/v2/test_errors.py -v --disable-warnings` +Expected: FAIL — `ModuleNotFoundError: No module named 'extralit.v2._api._errors'` + +- [ ] **Step 3: Implement** + +`src/extralit/v2/_api/_errors.py`: + +```python +from __future__ import annotations + +from typing import Any + + +class V2APIError(Exception): + """Base error for /api/v2 calls.""" + + def __init__(self, status_code: int, detail: Any = None): + self.status_code = status_code + self.detail = detail + super().__init__(f"HTTP {status_code}: {detail!r}") + + +class AuthError(V2APIError): + """401/403, raised after any transparent token refresh has already been attempted.""" + + +class NotFoundError(V2APIError): + """404.""" + + +class ValidationError(V2APIError): + """422. The server emits two body shapes (detail: str | list[{loc, msg}]); `.errors` is normalized.""" + + def __init__(self, status_code: int, detail: Any = None): + super().__init__(status_code, detail) + self.errors = normalize_validation_detail(detail) + + +def normalize_validation_detail(detail: Any) -> list[dict]: + if detail is None: + return [] + if isinstance(detail, str): + return [{"loc": [], "msg": detail}] + if isinstance(detail, list): + out = [] + for item in detail: + if isinstance(item, dict): + out.append({"loc": list(item.get("loc", [])), "msg": str(item.get("msg", item))}) + else: + out.append({"loc": [], "msg": str(item)}) + return out + return [{"loc": [], "msg": str(detail)}] + + +def error_from_response(status_code: int, body: Any) -> V2APIError: + detail = body.get("detail") if isinstance(body, dict) else body + if status_code in (401, 403): + return AuthError(status_code, detail) + if status_code == 404: + return NotFoundError(status_code, detail) + if status_code == 422: + return ValidationError(status_code, detail) + return V2APIError(status_code, detail) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/v2/test_errors.py -v --disable-warnings` +Expected: 5 PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/extralit/v2/_api/_errors.py tests/unit/v2/test_errors.py +git commit -m "feat(sdk-v2): error hierarchy with dual-shape 422 normalizer" +``` + +--- + +### Task 3: Async transport — auth modes, refresh-once, error mapping + +**Files:** +- Create: `src/extralit/v2/_api/_transport.py`, `tests/unit/v2/test_transport.py` + +**Interfaces:** +- Consumes: `error_from_response` from Task 2. +- Produces: + - `class AsyncTransport` — `def __init__(self, api_url: str, api_key: Optional[str] = None, username: Optional[str] = None, password: Optional[str] = None, timeout: float = 60.0, retries: int = 5, extra_headers: Optional[dict] = None)` + - `async def request(self, method: str, path: str, *, params: Optional[dict] = None, json: Optional[Any] = None) -> Any` — `path` is relative to `/api/v2` (e.g. `"/schemas"`); returns parsed JSON, or `None` for 204/empty bodies. Raises the Task 2 hierarchy on ≥400. + - `async def aclose(self) -> None` + +- [ ] **Step 1: Write the failing tests** + +`tests/unit/v2/test_transport.py`: + +```python +import pytest + +from extralit.v2._api._errors import AuthError, NotFoundError, ValidationError +from extralit.v2._api._transport import AsyncTransport + +pytestmark = pytest.mark.asyncio + +API = "http://test:6900" + + +async def test_api_key_header_sent(httpx_mock): + httpx_mock.add_response(url=f"{API}/api/v2/schemas?workspace_id=w1", json={"items": []}) + t = AsyncTransport(API, api_key="secret.key") + body = await t.request("GET", "/schemas", params={"workspace_id": "w1"}) + assert body == {"items": []} + assert httpx_mock.get_requests()[0].headers["X-Extralit-Api-Key"] == "secret.key" + await t.aclose() + + +async def test_password_login_then_bearer(httpx_mock): + httpx_mock.add_response( + method="POST", url=f"{API}/api/v2/token", status_code=201, + json={"access_token": "AT1", "refresh_token": "RT1"}, + ) + httpx_mock.add_response(url=f"{API}/api/v2/schemas?workspace_id=w1", json={"items": []}) + t = AsyncTransport(API, username="u", password="p") + await t.request("GET", "/schemas", params={"workspace_id": "w1"}) + token_req, api_req = httpx_mock.get_requests() + assert b"username=u" in token_req.content and b"password=p" in token_req.content + assert api_req.headers["Authorization"] == "Bearer AT1" + await t.aclose() + + +async def test_refresh_once_on_401_then_retry(httpx_mock): + httpx_mock.add_response( + method="POST", url=f"{API}/api/v2/token", status_code=201, + json={"access_token": "AT1", "refresh_token": "RT1"}, + ) + httpx_mock.add_response( + url=f"{API}/api/v2/schemas?workspace_id=w1", status_code=401, json={"detail": "expired"} + ) + httpx_mock.add_response( + method="POST", url=f"{API}/api/v2/token/refresh", status_code=201, + json={"access_token": "AT2", "refresh_token": "RT2"}, + ) + httpx_mock.add_response(url=f"{API}/api/v2/schemas?workspace_id=w1", json={"items": []}) + t = AsyncTransport(API, username="u", password="p") + body = await t.request("GET", "/schemas", params={"workspace_id": "w1"}) + assert body == {"items": []} + refresh_req = httpx_mock.get_requests()[2] + assert b"RT1" in refresh_req.content + assert httpx_mock.get_requests()[3].headers["Authorization"] == "Bearer AT2" + await t.aclose() + + +async def test_401_after_failed_refresh_raises_auth_error(httpx_mock): + httpx_mock.add_response( + method="POST", url=f"{API}/api/v2/token", status_code=201, + json={"access_token": "AT1", "refresh_token": "RT1"}, + ) + httpx_mock.add_response( + url=f"{API}/api/v2/schemas?workspace_id=w1", status_code=401, json={"detail": "expired"} + ) + httpx_mock.add_response(method="POST", url=f"{API}/api/v2/token/refresh", status_code=401, json={"detail": "no"}) + t = AsyncTransport(API, username="u", password="p") + with pytest.raises(AuthError): + await t.request("GET", "/schemas", params={"workspace_id": "w1"}) + await t.aclose() + + +async def test_api_key_401_raises_without_refresh(httpx_mock): + httpx_mock.add_response(url=f"{API}/api/v2/schemas?workspace_id=w1", status_code=401, json={"detail": "bad key"}) + t = AsyncTransport(API, api_key="bad") + with pytest.raises(AuthError): + await t.request("GET", "/schemas", params={"workspace_id": "w1"}) + assert len(httpx_mock.get_requests()) == 1 # no refresh attempt in api-key mode + await t.aclose() + + +async def test_error_mapping_and_204(httpx_mock): + httpx_mock.add_response(url=f"{API}/api/v2/schemas/x", status_code=404, json={"detail": "gone"}) + httpx_mock.add_response(method="POST", url=f"{API}/api/v2/schemas", status_code=422, json={"detail": "bad"}) + httpx_mock.add_response(method="DELETE", url=f"{API}/api/v2/schemas/y/records?ids=a", status_code=204) + t = AsyncTransport(API, api_key="k") + with pytest.raises(NotFoundError): + await t.request("GET", "/schemas/x") + with pytest.raises(ValidationError): + await t.request("POST", "/schemas", json={}) + assert await t.request("DELETE", "/schemas/y/records", params={"ids": "a"}) is None + await t.aclose() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/v2/test_transport.py -v --disable-warnings` +Expected: FAIL — `ModuleNotFoundError: No module named 'extralit.v2._api._transport'` + +- [ ] **Step 3: Implement** + +`src/extralit/v2/_api/_transport.py`: + +```python +from __future__ import annotations + +from typing import Any, Optional + +import httpx + +from extralit.v2._api._errors import AuthError, error_from_response + +_API_PREFIX = "/api/v2" + + +def _safe_json(response: httpx.Response) -> Any: + try: + return response.json() + except ValueError: + return response.text + + +class AsyncTransport: + """One httpx.AsyncClient per client instance. Auth modes: api_key header (default, + no token lifecycle) or username/password -> bearer JWT with a single transparent + refresh on 401 (then AuthError).""" + + def __init__( + self, + api_url: str, + api_key: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + timeout: float = 60.0, + retries: int = 5, + extra_headers: Optional[dict] = None, + ): + self.api_url = api_url.rstrip("/") + self._api_key = api_key + self._username = username + self._password = password + self._access_token: Optional[str] = None + self._refresh_token: Optional[str] = None + self._http = httpx.AsyncClient( + base_url=self.api_url, + timeout=timeout, + transport=httpx.AsyncHTTPTransport(retries=retries), + headers=extra_headers or {}, + ) + + def _auth_headers(self) -> dict: + if self._access_token: + return {"Authorization": f"Bearer {self._access_token}"} + if self._api_key: + return {"X-Extralit-Api-Key": self._api_key} + return {} + + async def _login(self) -> None: + response = await self._http.post( + f"{_API_PREFIX}/token", data={"username": self._username, "password": self._password} + ) + if response.status_code >= 400: + raise AuthError(response.status_code, _safe_json(response)) + payload = response.json() + self._access_token = payload["access_token"] + self._refresh_token = payload.get("refresh_token") + + async def _refresh(self) -> bool: + if not self._refresh_token: + return False + response = await self._http.post( + f"{_API_PREFIX}/token/refresh", json={"refresh_token": self._refresh_token} + ) + if response.status_code >= 400: + return False + payload = response.json() + self._access_token = payload["access_token"] + self._refresh_token = payload.get("refresh_token", self._refresh_token) + return True + + async def request( + self, + method: str, + path: str, + *, + params: Optional[dict] = None, + json: Optional[Any] = None, + ) -> Any: + if self._username and not self._access_token and not self._api_key: + await self._login() + response = await self._http.request( + method, f"{_API_PREFIX}{path}", params=params, json=json, headers=self._auth_headers() + ) + if response.status_code == 401 and self._access_token and await self._refresh(): + response = await self._http.request( + method, f"{_API_PREFIX}{path}", params=params, json=json, headers=self._auth_headers() + ) + if response.status_code >= 400: + raise error_from_response(response.status_code, _safe_json(response)) + if response.status_code == 204 or not response.content: + return None + return response.json() + + async def aclose(self) -> None: + await self._http.aclose() +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/v2/test_transport.py -v --disable-warnings` +Expected: 6 PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/extralit/v2/_api/_transport.py tests/unit/v2/test_transport.py +git commit -m "feat(sdk-v2): async transport with api-key/bearer auth and refresh-once" +``` + +--- + +### Task 4: Domain models and response-value wrap/unwrap + +**Files:** +- Create: `src/extralit/v2/models.py`, `tests/unit/v2/test_models.py` + +**Interfaces:** +- Consumes: generated DTOs from Task 1. +- Produces (all constructed via `Model.model_validate(payload)`): + - `class Schema(SchemaRead)`, `class Record(RecordRead)`, `class Question(QuestionRead)`, `class Suggestion(SuggestionRead)` — plain subclasses + - `class SchemaVersion(SchemaVersionRead)` with `def find_column(self, name: str) -> Optional[dict]` + - `class Response(ResponseRead)` with property `unwrapped_values: dict` + - `class SearchPage(BaseModel)` — `items: list[Record]`, `total: int` (approximate) + - `def wrap_response_values(values: dict) -> dict`, `def unwrap_response_values(values) -> dict` + - Re-exports: `ProjectionCell`, `ProjectionRecord`, `ProjectionView`, `ReferenceGroup`, `ReferenceView` + +- [ ] **Step 1: Write the failing tests** + +`tests/unit/v2/test_models.py`: + +```python +import uuid +from datetime import datetime, timezone + +from extralit.v2.models import ( + Record, + Response, + SchemaVersion, + SearchPage, + unwrap_response_values, + wrap_response_values, +) + + +def _version_payload(**overrides): + payload = { + "id": str(uuid.uuid4()), + "schema_id": str(uuid.uuid4()), + "version": 1, + "object_key": "schemas/x/v1.json", + "object_version_id": None, + "etag": "e", + "checksum": "c", + "parent_version_id": None, + "columns_cache": [{"name": "size", "dtype": "str"}, {"name": "country", "dtype": "str"}], + "review_widgets": {}, + "inserted_at": datetime.now(timezone.utc).isoformat(), + } + payload.update(overrides) + return payload + + +def test_schema_version_find_column(): + version = SchemaVersion.model_validate(_version_payload()) + assert version.find_column("size") == {"name": "size", "dtype": "str"} + assert version.find_column("nope") is None + + +def test_wrap_unwrap_roundtrip(): + """Server double-wraps response values ({name: {"value": ...}}) on both PUT and GET.""" + values = {"size": "120", "country": ["KE", "UG"]} + wrapped = wrap_response_values(values) + assert wrapped == {"size": {"value": "120"}, "country": {"value": ["KE", "UG"]}} + assert unwrap_response_values(wrapped) == values + assert unwrap_response_values(None) == {} + + +def test_response_unwrapped_values(): + response = Response.model_validate( + { + "id": str(uuid.uuid4()), + "record_id": str(uuid.uuid4()), + "user_id": str(uuid.uuid4()), + "values": {"size": {"value": "135"}}, + "status": "submitted", + "inserted_at": datetime.now(timezone.utc).isoformat(), + "updated_at": datetime.now(timezone.utc).isoformat(), + } + ) + assert response.unwrapped_values == {"size": "135"} + + +def test_search_page_holds_records(): + record = { + "id": str(uuid.uuid4()), + "schema_id": str(uuid.uuid4()), + "schema_version_id": str(uuid.uuid4()), + "reference": "10.1000/xyz", + "external_id": None, + "fields": {"size": "120"}, + "metadata": None, + "status": "pending", + "inserted_at": datetime.now(timezone.utc).isoformat(), + "updated_at": datetime.now(timezone.utc).isoformat(), + } + page = SearchPage(items=[Record.model_validate(record)], total=1) + assert page.items[0].reference == "10.1000/xyz" + assert page.total == 1 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/v2/test_models.py -v --disable-warnings` +Expected: FAIL — `ModuleNotFoundError: No module named 'extralit.v2.models'` + +- [ ] **Step 3: Implement** + +`src/extralit/v2/models.py` (no `from __future__ import annotations` here — pydantic models on 3.9, so use `Optional`/builtin generics directly): + +```python +from typing import Any, Optional + +from pydantic import BaseModel + +from extralit.v2._api._generated import ( + ProjectionCell, + ProjectionRecord, + ProjectionView, + QuestionRead, + RecordRead, + ReferenceGroup, + ReferenceView, + ResponseRead, + SchemaRead, + SchemaVersionRead, + SuggestionRead, +) + +__all__ = [ + "Schema", "SchemaVersion", "Record", "Question", "Suggestion", "Response", "SearchPage", + "ProjectionCell", "ProjectionRecord", "ProjectionView", "ReferenceGroup", "ReferenceView", + "wrap_response_values", "unwrap_response_values", +] + + +class Schema(SchemaRead): + pass + + +class SchemaVersion(SchemaVersionRead): + def find_column(self, name: str) -> Optional[dict]: + for column in self.columns_cache: + if column.get("name") == name: + return column + return None + + +class Record(RecordRead): + pass + + +class Question(QuestionRead): + pass + + +class Suggestion(SuggestionRead): + pass + + +def wrap_response_values(values: dict) -> dict: + """Server stores response values double-wrapped: {question_name: {"value": ...}}.""" + return {name: {"value": value} for name, value in values.items()} + + +def unwrap_response_values(values: Optional[dict]) -> dict: + return { + name: cell.get("value") if isinstance(cell, dict) else cell + for name, cell in (values or {}).items() + } + + +class Response(ResponseRead): + @property + def unwrapped_values(self) -> dict: + return unwrap_response_values(self.values) + + +class SearchPage(BaseModel): + """One page of records. `total` is approximate: stale index ids are skipped and + FTS saturates (~10k) — never present it as an exact count.""" + + items: "list[Record]" + total: int +``` + +Note: if `_generated.py` field types make subclass validation fail (e.g. enum vs str), check the generated field names — the contract test in Task 1 pinned the class names, and these tests pin the field behavior. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/v2/test_models.py -v --disable-warnings` +Expected: 4 PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/extralit/v2/models.py tests/unit/v2/test_models.py +git commit -m "feat(sdk-v2): domain models with double-wrap value helpers" +``` + +--- + +### Task 5: Schemas resource (create/list/get/get_by_name/update/publish/versions/columns) + +**Files:** +- Create: `src/extralit/v2/resources/__init__.py`, `src/extralit/v2/resources/_base.py`, `src/extralit/v2/resources/_schemas.py`, `tests/unit/v2/test_schemas_resource.py` + +**Interfaces:** +- Consumes: `AsyncTransport.request` (Task 3), models (Task 4), `NotFoundError` (Task 2). +- Produces: + - `class ResourceBase` — `def __init__(self, transport: AsyncTransport)`, attr `self._transport` + - `class Schemas(ResourceBase)` with async methods: + - `create(workspace_id, name, settings=None) -> Schema` + - `list(workspace_id) -> list[Schema]` + - `get(schema_id) -> Schema` + - `get_by_name(workspace_id, name) -> Schema` (raises `NotFoundError(404, ...)` when absent) + - `update(schema_id, *, name=None, settings=None) -> Schema` + - `publish(schema_id, schema, review_widgets=None) -> SchemaVersion` — `schema` is a Pandera `DataFrameSchema` (duck-typed via `.to_json()`) or an already-serialized JSON string + - `versions(schema_id) -> list[SchemaVersion]` + - `get_version(schema_id, version: int) -> SchemaVersion` — cached by `(schema_id, version)` (immutable) + - `columns(schema_id) -> list[dict]` + +- [ ] **Step 1: Write the failing tests** + +`tests/unit/v2/test_schemas_resource.py`: + +```python +import uuid +from datetime import datetime, timezone + +import pytest + +from extralit.v2._api._errors import NotFoundError +from extralit.v2._api._transport import AsyncTransport +from extralit.v2.resources import Schemas + +pytestmark = pytest.mark.asyncio + +API = "http://test:6900" +WS = str(uuid.uuid4()) +SCHEMA_ID = str(uuid.uuid4()) +NOW = datetime.now(timezone.utc).isoformat() + + +def _schema(name="trials"): + return { + "id": SCHEMA_ID, "name": name, "status": "draft", "current_version_id": None, + "settings": {}, "workspace_id": WS, "inserted_at": NOW, "updated_at": NOW, + } + + +def _version(version=1): + return { + "id": str(uuid.uuid4()), "schema_id": SCHEMA_ID, "version": version, + "object_key": f"schemas/{SCHEMA_ID}/v{version}.json", "object_version_id": None, + "etag": "e", "checksum": "c", "parent_version_id": None, + "columns_cache": [{"name": "size"}], "review_widgets": {}, "inserted_at": NOW, + } + + +@pytest.fixture +async def schemas(): + transport = AsyncTransport(API, api_key="k") + yield Schemas(transport) + await transport.aclose() + + +async def test_create_and_get(httpx_mock, schemas): + httpx_mock.add_response(method="POST", url=f"{API}/api/v2/schemas", status_code=201, json=_schema()) + created = await schemas.create(WS, "trials") + assert created.name == "trials" + body = httpx_mock.get_requests()[0].read() + assert b'"workspace_id"' in body and b'"trials"' in body + + +async def test_get_by_name_found_and_missing(httpx_mock, schemas): + listing = {"items": [_schema("other") | {"id": str(uuid.uuid4())}, _schema("trials")]} + httpx_mock.add_response(url=f"{API}/api/v2/schemas?workspace_id={WS}", json=listing) + found = await schemas.get_by_name(WS, "trials") + assert str(found.id) == SCHEMA_ID + httpx_mock.add_response(url=f"{API}/api/v2/schemas?workspace_id={WS}", json={"items": []}) + with pytest.raises(NotFoundError): + await schemas.get_by_name(WS, "trials") + + +async def test_publish_accepts_pandera_object_or_string(httpx_mock, schemas): + httpx_mock.add_response( + method="POST", url=f"{API}/api/v2/schemas/{SCHEMA_ID}/versions", status_code=201, json=_version() + ) + + class FakePandera: # duck-type: anything with .to_json() + def to_json(self): + return '{"columns": {"size": {}}}' + + version = await schemas.publish(SCHEMA_ID, FakePandera(), review_widgets={"size": {"widget": "text"}}) + assert version.version == 1 + sent = httpx_mock.get_requests()[0].read() + assert b'"columns"' in sent and b'"review_widgets"' in sent + + +async def test_get_version_cached(httpx_mock, schemas): + httpx_mock.add_response(url=f"{API}/api/v2/schemas/{SCHEMA_ID}/versions/1", json=_version()) + v1 = await schemas.get_version(SCHEMA_ID, 1) + v1_again = await schemas.get_version(SCHEMA_ID, 1) # served from cache: no second request + assert v1_again is v1 + assert len(httpx_mock.get_requests()) == 1 + + +async def test_versions_and_columns(httpx_mock, schemas): + httpx_mock.add_response(url=f"{API}/api/v2/schemas/{SCHEMA_ID}/versions", json=[_version(1), _version(2)]) + httpx_mock.add_response(url=f"{API}/api/v2/schemas/{SCHEMA_ID}/columns", json=[{"name": "size"}]) + assert [v.version for v in await schemas.versions(SCHEMA_ID)] == [1, 2] + assert await schemas.columns(SCHEMA_ID) == [{"name": "size"}] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/v2/test_schemas_resource.py -v --disable-warnings` +Expected: FAIL — `ImportError` (no `extralit.v2.resources`) + +- [ ] **Step 3: Implement** + +`src/extralit/v2/resources/_base.py`: + +```python +from extralit.v2._api._transport import AsyncTransport + + +class ResourceBase: + def __init__(self, transport: AsyncTransport): + self._transport = transport +``` + +`src/extralit/v2/resources/_schemas.py`: + +```python +from __future__ import annotations + +from typing import Any, Optional + +from extralit.v2._api._errors import NotFoundError +from extralit.v2._api._transport import AsyncTransport +from extralit.v2.models import Schema, SchemaVersion +from extralit.v2.resources._base import ResourceBase + + +class Schemas(ResourceBase): + def __init__(self, transport: AsyncTransport): + super().__init__(transport) + self._version_cache: dict = {} # (schema_id, version) -> SchemaVersion; versions are immutable + + async def create(self, workspace_id, name: str, settings: Optional[dict] = None) -> Schema: + payload = await self._transport.request( + "POST", "/schemas", + json={"name": name, "workspace_id": str(workspace_id), "settings": settings or {}}, + ) + return Schema.model_validate(payload) + + async def list(self, workspace_id) -> list[Schema]: + payload = await self._transport.request("GET", "/schemas", params={"workspace_id": str(workspace_id)}) + return [Schema.model_validate(item) for item in payload["items"]] + + async def get(self, schema_id) -> Schema: + return Schema.model_validate(await self._transport.request("GET", f"/schemas/{schema_id}")) + + async def get_by_name(self, workspace_id, name: str) -> Schema: + for schema in await self.list(workspace_id): + if schema.name == name: + return schema + raise NotFoundError(404, f"schema named {name!r} not found in workspace {workspace_id}") + + async def update(self, schema_id, *, name: Optional[str] = None, settings: Optional[dict] = None) -> Schema: + body: dict = {} + if name is not None: + body["name"] = name + if settings is not None: + body["settings"] = settings + return Schema.model_validate(await self._transport.request("PUT", f"/schemas/{schema_id}", json=body)) + + async def publish(self, schema_id, schema: Any, review_widgets: Optional[dict] = None) -> SchemaVersion: + """Publish a new schema version. `schema` is a pandera DataFrameSchema (anything with + .to_json()) or the already-serialized JSON string. review_widgets ride out-of-band + because pandera's to_json() drops Column.metadata.""" + body = schema.to_json() if hasattr(schema, "to_json") else schema + payload = await self._transport.request( + "POST", f"/schemas/{schema_id}/versions", + json={"body": body, "review_widgets": review_widgets or {}}, + ) + version = SchemaVersion.model_validate(payload) + self._version_cache[(str(schema_id), version.version)] = version + return version + + async def versions(self, schema_id) -> list[SchemaVersion]: + payload = await self._transport.request("GET", f"/schemas/{schema_id}/versions") + return [SchemaVersion.model_validate(item) for item in payload] + + async def get_version(self, schema_id, version: int) -> SchemaVersion: + key = (str(schema_id), version) + if key not in self._version_cache: + payload = await self._transport.request("GET", f"/schemas/{schema_id}/versions/{version}") + self._version_cache[key] = SchemaVersion.model_validate(payload) + return self._version_cache[key] + + async def columns(self, schema_id) -> list[dict]: + return await self._transport.request("GET", f"/schemas/{schema_id}/columns") +``` + +`src/extralit/v2/resources/__init__.py`: + +```python +from extralit.v2.resources._schemas import Schemas + +__all__ = ["Schemas"] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/v2/test_schemas_resource.py -v --disable-warnings` +Expected: 5 PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/extralit/v2/resources tests/unit/v2/test_schemas_resource.py +git commit -m "feat(sdk-v2): Schemas resource with immutable version caching" +``` + +--- + +### Task 6: Questions resource with cached name→id map + +**Files:** +- Create: `src/extralit/v2/resources/_questions.py`, `tests/unit/v2/test_questions_resource.py` +- Modify: `src/extralit/v2/resources/__init__.py` + +**Interfaces:** +- Consumes: `ResourceBase`, `Question` model, `NotFoundError`. +- Produces: `class Questions(ResourceBase)`: + - `async def list(self, schema_id) -> list[Question]` + - `async def get(self, question_id) -> Question` + - `async def id_for(self, schema_id, name: str) -> UUID` — cached per schema; on a cache miss for a known schema it refetches once (a question may have been added) before raising `NotFoundError`. **This is the name↔id join used by Suggestions — suggestions key by question id, while cells/response values key by name.** + - `def invalidate(self, schema_id) -> None` — drops the cached map + +- [ ] **Step 1: Write the failing tests** + +`tests/unit/v2/test_questions_resource.py`: + +```python +import uuid +from datetime import datetime, timezone + +import pytest + +from extralit.v2._api._errors import NotFoundError +from extralit.v2._api._transport import AsyncTransport +from extralit.v2.resources import Questions + +pytestmark = pytest.mark.asyncio + +API = "http://test:6900" +SCHEMA_ID = str(uuid.uuid4()) +Q_SIZE = str(uuid.uuid4()) +NOW = datetime.now(timezone.utc).isoformat() + + +def _question(qid, name): + return { + "id": qid, "schema_id": SCHEMA_ID, "name": name, "title": name.title(), "description": None, + "type": "text", "columns": [name], "settings": {}, "required": False, + "inserted_at": NOW, "updated_at": NOW, + } + + +@pytest.fixture +async def questions(): + transport = AsyncTransport(API, api_key="k") + yield Questions(transport) + await transport.aclose() + + +async def test_list_and_id_for_uses_one_fetch(httpx_mock, questions): + httpx_mock.add_response( + url=f"{API}/api/v2/schemas/{SCHEMA_ID}/questions", json={"items": [_question(Q_SIZE, "size")]} + ) + assert [q.name for q in await questions.list(SCHEMA_ID)] == ["size"] + assert str(await questions.id_for(SCHEMA_ID, "size")) == Q_SIZE # served from cache + assert len(httpx_mock.get_requests()) == 1 + + +async def test_id_for_refetches_once_then_raises(httpx_mock, questions): + httpx_mock.add_response( + url=f"{API}/api/v2/schemas/{SCHEMA_ID}/questions", json={"items": [_question(Q_SIZE, "size")]} + ) + await questions.list(SCHEMA_ID) + q_new = str(uuid.uuid4()) + httpx_mock.add_response( + url=f"{API}/api/v2/schemas/{SCHEMA_ID}/questions", + json={"items": [_question(Q_SIZE, "size"), _question(q_new, "dosage")]}, + ) + assert str(await questions.id_for(SCHEMA_ID, "dosage")) == q_new # miss -> refetch -> hit + httpx_mock.add_response(url=f"{API}/api/v2/schemas/{SCHEMA_ID}/questions", json={"items": []}) + with pytest.raises(NotFoundError): + await questions.id_for(SCHEMA_ID, "ghost") +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/v2/test_questions_resource.py -v --disable-warnings` +Expected: FAIL — `ImportError: cannot import name 'Questions'` + +- [ ] **Step 3: Implement** + +`src/extralit/v2/resources/_questions.py`: + +```python +from __future__ import annotations + +from uuid import UUID + +from extralit.v2._api._errors import NotFoundError +from extralit.v2._api._transport import AsyncTransport +from extralit.v2.models import Question +from extralit.v2.resources._base import ResourceBase + + +class Questions(ResourceBase): + """Callers address questions by NAME; the server keys suggestions by question ID + (cells/response values key by name). This resource owns that join via a cached map.""" + + def __init__(self, transport: AsyncTransport): + super().__init__(transport) + self._maps: dict = {} # str(schema_id) -> {name: UUID} + + async def list(self, schema_id) -> list[Question]: + payload = await self._transport.request("GET", f"/schemas/{schema_id}/questions") + items = [Question.model_validate(item) for item in payload["items"]] + self._maps[str(schema_id)] = {q.name: q.id for q in items} + return items + + async def get(self, question_id) -> Question: + return Question.model_validate(await self._transport.request("GET", f"/questions/{question_id}")) + + async def id_for(self, schema_id, name: str) -> UUID: + key = str(schema_id) + if key not in self._maps or name not in self._maps[key]: + await self.list(schema_id) # refetch once: the question may be newly created + if name not in self._maps.get(key, {}): + raise NotFoundError(404, f"question named {name!r} not found in schema {schema_id}") + return self._maps[key][name] + + def invalidate(self, schema_id) -> None: + self._maps.pop(str(schema_id), None) +``` + +Update `src/extralit/v2/resources/__init__.py`: + +```python +from extralit.v2.resources._questions import Questions +from extralit.v2.resources._schemas import Schemas + +__all__ = ["Questions", "Schemas"] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/v2/test_questions_resource.py -v --disable-warnings` +Expected: 2 PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/extralit/v2/resources tests/unit/v2/test_questions_resource.py +git commit -m "feat(sdk-v2): Questions resource owning the name-to-id join" +``` + +--- + +### Task 7: Records resource — chunked concurrent bulk upsert, search, list, delete + +**Files:** +- Create: `src/extralit/v2/resources/_records.py`, `tests/unit/v2/test_records_resource.py` +- Modify: `src/extralit/v2/resources/__init__.py` + +**Interfaces:** +- Consumes: `ResourceBase`, `Record`/`SearchPage`/`ReferenceView` models. +- Produces: `class Records(ResourceBase)`: + - `async def bulk_upsert(self, schema_id, items, *, reference=None, max_concurrency=4, on_progress=None) -> list[Record]` — `items`: list of dicts (full `RecordUpsert` shape if a `"fields"` key is present, else treated as bare field dicts) or a pandas DataFrame (lazy import; rows become field dicts, a `reference` column is lifted out). Chunks at 500, dispatches chunks concurrently under a semaphore, preserves input order in the returned list. `on_progress(done_count, total_count)` called per completed chunk. + - `async def search(self, schema_id, *, text=None, filters=None, offset=0, limit=50) -> SearchPage` — `filters`: list of `(column, op, value)` tuples or `{"column","op","value"}` dicts, `op ∈ {eq,in,ge,le}`. + - `async def list(self, schema_id, *, offset=0, limit=50, status=None, reference=None) -> SearchPage` + - `async def delete(self, schema_id, ids) -> None` — chunks at 100 ids per request (comma-separated query param). + - `async def get_reference(self, workspace_id, reference: str) -> ReferenceView` — path is NOT url-encoded for slashes (server route is `{reference:path}`). + +- [ ] **Step 1: Write the failing tests** + +`tests/unit/v2/test_records_resource.py`: + +```python +import json +import uuid +from datetime import datetime, timezone + +import pytest + +from extralit.v2._api._transport import AsyncTransport +from extralit.v2.resources import Records + +pytestmark = pytest.mark.asyncio + +API = "http://test:6900" +SCHEMA_ID = str(uuid.uuid4()) +WS = str(uuid.uuid4()) +NOW = datetime.now(timezone.utc).isoformat() + + +def _record(i): + return { + "id": str(uuid.uuid4()), "schema_id": SCHEMA_ID, "schema_version_id": str(uuid.uuid4()), + "reference": "10.1000/xyz", "external_id": str(i), "fields": {"size": str(i)}, + "metadata": None, "status": "pending", "inserted_at": NOW, "updated_at": NOW, + } + + +@pytest.fixture +async def records(): + transport = AsyncTransport(API, api_key="k") + yield Records(transport) + await transport.aclose() + + +async def test_bulk_upsert_chunks_at_500_and_preserves_order(httpx_mock, records): + url = f"{API}/api/v2/schemas/{SCHEMA_ID}/records:bulk-upsert" + + def responder(request): + import httpx + + sent = json.loads(request.read())["items"] + assert len(sent) <= 500 + return httpx.Response(200, json={"items": [_record(item["external_id"]) for item in sent], "total": len(sent)}) + + for _ in range(3): + httpx_mock.add_callback(responder, method="POST", url=url) + + items = [{"fields": {"size": str(i)}, "reference": "10.1000/xyz", "external_id": str(i)} for i in range(1200)] + progress = [] + result = await records.bulk_upsert(SCHEMA_ID, items, on_progress=lambda done, total: progress.append((done, total))) + assert len(result) == 1200 + assert [r.external_id for r in result] == [str(i) for i in range(1200)] # input order preserved + assert len(httpx_mock.get_requests()) == 3 # 500 + 500 + 200 + assert progress[-1] == (1200, 1200) + + +async def test_bulk_upsert_bare_fields_and_shared_reference(httpx_mock, records): + url = f"{API}/api/v2/schemas/{SCHEMA_ID}/records:bulk-upsert" + httpx_mock.add_response(method="POST", url=url, json={"items": [_record(0)], "total": 1}) + await records.bulk_upsert(SCHEMA_ID, [{"size": "120"}], reference="10.1000/xyz") + sent = json.loads(httpx_mock.get_requests()[0].read())["items"] + assert sent == [{"fields": {"size": "120"}, "reference": "10.1000/xyz"}] + + +async def test_bulk_upsert_without_reference_raises(records): + with pytest.raises(ValueError, match="reference"): + await records.bulk_upsert(SCHEMA_ID, [{"size": "120"}]) + + +async def test_bulk_upsert_accepts_dataframe(httpx_mock, records): + pandas = pytest.importorskip("pandas") + url = f"{API}/api/v2/schemas/{SCHEMA_ID}/records:bulk-upsert" + httpx_mock.add_response(method="POST", url=url, json={"items": [_record(0), _record(1)], "total": 2}) + frame = pandas.DataFrame([ + {"size": "120", "reference": "10.1000/a"}, + {"size": "135", "reference": "10.1000/b"}, + ]) + await records.bulk_upsert(SCHEMA_ID, frame) + sent = json.loads(httpx_mock.get_requests()[0].read())["items"] + assert sent == [ + {"fields": {"size": "120"}, "reference": "10.1000/a"}, + {"fields": {"size": "135"}, "reference": "10.1000/b"}, + ] + + +async def test_search_normalizes_tuple_filters(httpx_mock, records): + url = f"{API}/api/v2/schemas/{SCHEMA_ID}/records:search" + httpx_mock.add_response(method="POST", url=url, json={"items": [_record(1)], "total": 41}) + page = await records.search(SCHEMA_ID, text="tumor", filters=[("age", "ge", 18)], limit=10) + assert page.total == 41 + body = json.loads(httpx_mock.get_requests()[0].read()) + assert body == { + "text": "tumor", + "filters": [{"column": "age", "op": "ge", "value": 18}], + "offset": 0, + "limit": 10, + } + + +async def test_list_passes_status_and_reference(httpx_mock, records): + httpx_mock.add_response( + url=f"{API}/api/v2/schemas/{SCHEMA_ID}/records?offset=0&limit=50&status=pending&reference=10.1000%2Fxyz", + json={"items": [], "total": 0}, + ) + page = await records.list(SCHEMA_ID, status="pending", reference="10.1000/xyz") + assert page.items == [] and page.total == 0 + + +async def test_delete_chunks_at_100(httpx_mock, records): + for _ in range(2): + httpx_mock.add_response( + method="DELETE", + url=__import__("re").compile(rf"{API}/api/v2/schemas/{SCHEMA_ID}/records\?ids=.*"), + status_code=204, + ) + await records.delete(SCHEMA_ID, [f"id-{i}" for i in range(150)]) + reqs = httpx_mock.get_requests() + assert len(reqs) == 2 + assert len(reqs[0].url.params["ids"].split(",")) == 100 + assert len(reqs[1].url.params["ids"].split(",")) == 50 + + +async def test_get_reference_keeps_slashes(httpx_mock, records): + httpx_mock.add_response( + url=f"{API}/api/v2/references/10.1000/j.abc?workspace_id={WS}", + json={"reference": "10.1000/j.abc", "groups": [], "total_records": 0}, + ) + view = await records.get_reference(WS, "10.1000/j.abc") + assert view.reference == "10.1000/j.abc" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/v2/test_records_resource.py -v --disable-warnings` +Expected: FAIL — `ImportError: cannot import name 'Records'` + +- [ ] **Step 3: Implement** + +`src/extralit/v2/resources/_records.py`: + +```python +from __future__ import annotations + +import asyncio +from typing import Any, Callable, Optional + +from extralit.v2.models import Record, ReferenceView, SearchPage +from extralit.v2.resources._base import ResourceBase + +BULK_UPSERT_MAX_ITEMS = 500 # server cap (RECORDS_BULK_UPSERT_MAX_ITEMS) +DELETE_MAX_IDS = 100 # server cap (DELETE_RECORDS_LIMIT) + + +def _normalize_items(items: Any, reference: Optional[str]) -> list[dict]: + if hasattr(items, "to_dict") and hasattr(items, "columns"): # pandas.DataFrame, kept lazy + items = items.to_dict(orient="records") + normalized = [] + for item in items: + if "fields" in item: + entry = dict(item) + else: + fields = dict(item) + entry = {"fields": fields} + if "reference" in fields: + entry["reference"] = fields.pop("reference") + if reference is not None: + entry.setdefault("reference", reference) + if "reference" not in entry: + raise ValueError("every record needs a reference (per-item or via the reference= argument)") + normalized.append(entry) + return normalized + + +def _normalize_filters(filters: Optional[list]) -> list[dict]: + normalized = [] + for item in filters or []: + if isinstance(item, dict): + normalized.append({"column": item["column"], "op": item["op"], "value": item["value"]}) + else: + column, op, value = item + normalized.append({"column": column, "op": op, "value": value}) + return normalized + + +class Records(ResourceBase): + async def bulk_upsert( + self, + schema_id, + items: Any, + *, + reference: Optional[str] = None, + max_concurrency: int = 4, + on_progress: Optional[Callable] = None, + ) -> list[Record]: + """Idempotent on external_id; metadata is patch-like (omitted keys preserved). + Auto-chunks at the server's 500-item cap; chunks fly concurrently but the + returned list preserves input order.""" + normalized = _normalize_items(items, reference) + chunks = [normalized[i : i + BULK_UPSERT_MAX_ITEMS] for i in range(0, len(normalized), BULK_UPSERT_MAX_ITEMS)] + results: list = [None] * len(chunks) + total = len(normalized) + done = 0 + semaphore = asyncio.Semaphore(max_concurrency) + + async def _run(index: int, chunk: list[dict]) -> None: + nonlocal done + async with semaphore: + payload = await self._transport.request( + "POST", f"/schemas/{schema_id}/records:bulk-upsert", json={"items": chunk} + ) + results[index] = payload["items"] + done += len(chunk) + if on_progress: + on_progress(done, total) + + await asyncio.gather(*(_run(i, c) for i, c in enumerate(chunks))) + return [Record.model_validate(item) for chunk in results for item in chunk] + + async def search( + self, + schema_id, + *, + text: Optional[str] = None, + filters: Optional[list] = None, + offset: int = 0, + limit: int = 50, + ) -> SearchPage: + payload = await self._transport.request( + "POST", + f"/schemas/{schema_id}/records:search", + json={"text": text, "filters": _normalize_filters(filters), "offset": offset, "limit": limit}, + ) + return SearchPage(items=[Record.model_validate(i) for i in payload["items"]], total=payload["total"]) + + async def list( + self, + schema_id, + *, + offset: int = 0, + limit: int = 50, + status: Optional[str] = None, + reference: Optional[str] = None, + ) -> SearchPage: + params: dict = {"offset": offset, "limit": limit} + if status is not None: + params["status"] = status + if reference is not None: + params["reference"] = reference + payload = await self._transport.request("GET", f"/schemas/{schema_id}/records", params=params) + return SearchPage(items=[Record.model_validate(i) for i in payload["items"]], total=payload["total"]) + + async def delete(self, schema_id, ids: list) -> None: + id_strings = [str(record_id) for record_id in ids] + for start in range(0, len(id_strings), DELETE_MAX_IDS): + chunk = id_strings[start : start + DELETE_MAX_IDS] + await self._transport.request( + "DELETE", f"/schemas/{schema_id}/records", params={"ids": ",".join(chunk)} + ) + + async def get_reference(self, workspace_id, reference: str) -> ReferenceView: + # Slashes stay raw: the server route is /references/{reference:path}. + payload = await self._transport.request( + "GET", f"/references/{reference}", params={"workspace_id": str(workspace_id)} + ) + return ReferenceView.model_validate(payload) +``` + +Update `src/extralit/v2/resources/__init__.py`: + +```python +from extralit.v2.resources._questions import Questions +from extralit.v2.resources._records import Records +from extralit.v2.resources._schemas import Schemas + +__all__ = ["Questions", "Records", "Schemas"] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/v2/test_records_resource.py -v --disable-warnings` +Expected: 8 PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/extralit/v2/resources tests/unit/v2/test_records_resource.py +git commit -m "feat(sdk-v2): Records resource with chunked concurrent bulk upsert and search" +``` + +--- + +### Task 8: Suggestions, Projections, and Responses (read-only) resources + +**Files:** +- Create: `src/extralit/v2/resources/_annotation.py`, `src/extralit/v2/resources/_projections.py`, `tests/unit/v2/test_annotation_resources.py` +- Modify: `src/extralit/v2/resources/__init__.py` + +**Interfaces:** +- Consumes: `Questions.id_for` (Task 6), models (Task 4). +- Produces: + - `class Suggestions(ResourceBase)` — `def __init__(self, transport, questions: Questions)`; + - `async def upsert(self, record, question, value, *, score=None, agent=None, type=None, schema_id=None) -> Suggestion` — `record`: a `Record` domain object or a record id; `question`: a question id (UUID/uuid-string) or a question **name**. A name needs a schema to resolve against: taken from `record.schema_id` when a `Record` object was passed, else from `schema_id=`; otherwise `ValueError`. + - `async def list(self, record_id) -> list[Suggestion]` + - `class Responses(ResourceBase)` — `async def get(self, record_id) -> Optional[Response]` — the server returns literal `null` with 200 when no response exists: map to `None`, not an error. + - `class Projections(ResourceBase)` — `async def get(self, workspace_id, reference: str) -> ProjectionView` + +- [ ] **Step 1: Write the failing tests** + +`tests/unit/v2/test_annotation_resources.py`: + +```python +import json +import uuid +from datetime import datetime, timezone + +import pytest + +from extralit.v2._api._transport import AsyncTransport +from extralit.v2.models import Record +from extralit.v2.resources import Projections, Questions, Responses, Suggestions + +pytestmark = pytest.mark.asyncio + +API = "http://test:6900" +SCHEMA_ID = str(uuid.uuid4()) +RECORD_ID = str(uuid.uuid4()) +Q_SIZE = str(uuid.uuid4()) +WS = str(uuid.uuid4()) +NOW = datetime.now(timezone.utc).isoformat() + + +def _suggestion(): + return { + "id": str(uuid.uuid4()), "record_id": RECORD_ID, "question_id": Q_SIZE, "value": "120", + "score": 0.9, "agent": "claude", "type": "model", "inserted_at": NOW, "updated_at": NOW, + } + + +def _record_obj(): + return Record.model_validate( + { + "id": RECORD_ID, "schema_id": SCHEMA_ID, "schema_version_id": str(uuid.uuid4()), + "reference": "10.1000/xyz", "external_id": None, "fields": {}, "metadata": None, + "status": "pending", "inserted_at": NOW, "updated_at": NOW, + } + ) + + +@pytest.fixture +async def transport(): + t = AsyncTransport(API, api_key="k") + yield t + await t.aclose() + + +async def test_upsert_resolves_question_name_via_record_object(httpx_mock, transport): + httpx_mock.add_response( + url=f"{API}/api/v2/schemas/{SCHEMA_ID}/questions", + json={"items": [{ + "id": Q_SIZE, "schema_id": SCHEMA_ID, "name": "size", "title": "Size", "description": None, + "type": "text", "columns": ["size"], "settings": {}, "required": False, + "inserted_at": NOW, "updated_at": NOW, + }]}, + ) + httpx_mock.add_response( + method="PUT", url=f"{API}/api/v2/records/{RECORD_ID}/suggestions", json=_suggestion() + ) + questions = Questions(transport) + suggestions = Suggestions(transport, questions) + result = await suggestions.upsert(_record_obj(), "size", "120", score=0.9, agent="claude") + assert str(result.question_id) == Q_SIZE + body = json.loads(httpx_mock.get_requests()[-1].read()) + assert body["question_id"] == Q_SIZE and body["agent"] == "claude" and body["score"] == 0.9 + + +async def test_upsert_name_without_schema_raises(transport): + suggestions = Suggestions(transport, Questions(transport)) + with pytest.raises(ValueError, match="schema_id"): + await suggestions.upsert(RECORD_ID, "size", "120") + + +async def test_upsert_accepts_question_id_directly(httpx_mock, transport): + httpx_mock.add_response( + method="PUT", url=f"{API}/api/v2/records/{RECORD_ID}/suggestions", json=_suggestion() + ) + suggestions = Suggestions(transport, Questions(transport)) + await suggestions.upsert(RECORD_ID, Q_SIZE, "120") # no questions fetch needed + assert len(httpx_mock.get_requests()) == 1 + + +async def test_response_get_maps_null_to_none(httpx_mock, transport): + httpx_mock.add_response(url=f"{API}/api/v2/records/{RECORD_ID}/responses", json=None) + assert await Responses(transport).get(RECORD_ID) is None + + +async def test_response_get_unwraps_values(httpx_mock, transport): + httpx_mock.add_response( + url=f"{API}/api/v2/records/{RECORD_ID}/responses", + json={ + "id": str(uuid.uuid4()), "record_id": RECORD_ID, "user_id": str(uuid.uuid4()), + "values": {"size": {"value": "135"}}, "status": "submitted", + "inserted_at": NOW, "updated_at": NOW, + }, + ) + response = await Responses(transport).get(RECORD_ID) + assert response.unwrapped_values == {"size": "135"} + + +async def test_projection_get(httpx_mock, transport): + httpx_mock.add_response( + url=f"{API}/api/v2/projection/references/10.1000/j.abc?workspace_id={WS}", + json={ + "reference": "10.1000/j.abc", + "records": [{ + "record_id": RECORD_ID, "schema_id": SCHEMA_ID, "reference": "10.1000/j.abc", + "cells": [{"question_name": "size", "value": "120", "source": "suggestion"}], + }], + "total_records": 1, + }, + ) + view = await Projections(transport).get(WS, "10.1000/j.abc") + assert view.records[0].cells[0].source == "suggestion" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/v2/test_annotation_resources.py -v --disable-warnings` +Expected: FAIL — `ImportError` on `Projections`/`Responses`/`Suggestions` + +- [ ] **Step 3: Implement** + +`src/extralit/v2/resources/_annotation.py`: + +```python +from __future__ import annotations + +import uuid +from typing import Any, Optional + +from extralit.v2._api._transport import AsyncTransport +from extralit.v2.models import Response, Suggestion +from extralit.v2.resources._base import ResourceBase +from extralit.v2.resources._questions import Questions + + +def _as_question_id(question: Any) -> Optional[str]: + try: + return str(uuid.UUID(str(question))) + except (ValueError, AttributeError, TypeError): + return None + + +class Suggestions(ResourceBase): + def __init__(self, transport: AsyncTransport, questions: Questions): + super().__init__(transport) + self._questions = questions + + async def upsert( + self, + record: Any, + question: Any, + value: Any, + *, + score: Optional[Any] = None, + agent: Optional[str] = None, + type: Optional[str] = None, + schema_id: Optional[Any] = None, + ) -> Suggestion: + """Upsert one suggestion per (record, question). Suggestions key by question ID on + the wire, but callers may pass a question NAME — resolved via the record's schema.""" + record_id = getattr(record, "id", record) + question_id = _as_question_id(question) + if question_id is None: # a name: resolve against the record's schema + resolve_schema = getattr(record, "schema_id", None) or schema_id + if resolve_schema is None: + raise ValueError( + "resolving a question name requires a Record object or an explicit schema_id=" + ) + question_id = str(await self._questions.id_for(resolve_schema, question)) + body: dict = {"question_id": question_id, "value": value} + if score is not None: + body["score"] = score + if agent is not None: + body["agent"] = agent + if type is not None: + body["type"] = type + payload = await self._transport.request("PUT", f"/records/{record_id}/suggestions", json=body) + return Suggestion.model_validate(payload) + + async def list(self, record_id) -> list[Suggestion]: + payload = await self._transport.request("GET", f"/records/{record_id}/suggestions") + return [Suggestion.model_validate(item) for item in payload["items"]] + + +class Responses(ResourceBase): + async def get(self, record_id) -> Optional[Response]: + """GET returns literal `null` with 200 (not 404) when no response exists yet.""" + payload = await self._transport.request("GET", f"/records/{record_id}/responses") + return None if payload is None else Response.model_validate(payload) +``` + +`src/extralit/v2/resources/_projections.py`: + +```python +from extralit.v2.models import ProjectionView +from extralit.v2.resources._base import ResourceBase + + +class Projections(ResourceBase): + async def get(self, workspace_id, reference: str) -> ProjectionView: + """Response-or-suggestion per question for every record sharing this reference. + Slashes stay raw: the server route is /projection/references/{reference:path}.""" + payload = await self._transport.request( + "GET", f"/projection/references/{reference}", params={"workspace_id": str(workspace_id)} + ) + return ProjectionView.model_validate(payload) +``` + +Update `src/extralit/v2/resources/__init__.py`: + +```python +from extralit.v2.resources._annotation import Responses, Suggestions +from extralit.v2.resources._projections import Projections +from extralit.v2.resources._questions import Questions +from extralit.v2.resources._records import Records +from extralit.v2.resources._schemas import Schemas + +__all__ = ["Projections", "Questions", "Records", "Responses", "Schemas", "Suggestions"] +``` + +Note: `json=None` in the `test_response_get_maps_null_to_none` mock makes httpx return a body of `null` — the transport returns `None` because `response.json()` is `None`? No: the transport checks `not response.content` — a `null` body has content `b"null"`, so `response.json()` returns `None` naturally. Either path yields `None`; the test pins the behavior. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/v2/test_annotation_resources.py -v --disable-warnings` +Expected: 6 PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/extralit/v2/resources tests/unit/v2/test_annotation_resources.py +git commit -m "feat(sdk-v2): Suggestions/Responses/Projections resources with name-id join" +``` + +--- + +### Task 9: AsyncClient assembly with env/credentials fallback + +**Files:** +- Create: `src/extralit/v2/client.py`, `tests/unit/v2/test_client.py` +- Modify: `src/extralit/v2/__init__.py` + +**Interfaces:** +- Consumes: everything above; `extralit.client.login.ExtralitCredentials` (the one allowed v1 import). +- Produces: + - `class AsyncClient` — `def __init__(self, api_url=None, api_key=None, username=None, password=None, timeout=60.0, retries=5)`. Resolution order: explicit args → `EXTRALIT_API_URL`/`EXTRALIT_API_KEY` env → `~/.extralit/credentials.json`. Raises `ValueError` if unresolvable. + - Attributes: `.schemas: Schemas`, `.questions: Questions`, `.records: Records`, `.suggestions: Suggestions`, `.projections: Projections`, `.responses: Responses` (plus `.references` → `records.get_reference` lives on Records; expose `get_reference` through `client.records`). + - `async def aclose()`, `async def __aenter__/__aexit__` + - `extralit.v2.__init__` exports: `AsyncClient` + all names from `models` + +- [ ] **Step 1: Write the failing tests** + +`tests/unit/v2/test_client.py`: + +```python +import json + +import pytest + +from extralit.v2 import AsyncClient + +pytestmark = pytest.mark.asyncio + +API = "http://test:6900" + + +async def test_explicit_args_and_resource_wiring(httpx_mock): + httpx_mock.add_response(url=f"{API}/api/v2/schemas?workspace_id=w", json={"items": []}) + async with AsyncClient(api_url=API, api_key="k") as client: + assert await client.schemas.list("w") == [] + for name in ("schemas", "questions", "records", "suggestions", "projections", "responses"): + assert hasattr(client, name) + + +async def test_env_fallback(monkeypatch, httpx_mock): + monkeypatch.setenv("EXTRALIT_API_URL", API) + monkeypatch.setenv("EXTRALIT_API_KEY", "env-key") + httpx_mock.add_response(url=f"{API}/api/v2/schemas?workspace_id=w", json={"items": []}) + async with AsyncClient() as client: + await client.schemas.list("w") + assert httpx_mock.get_requests()[0].headers["X-Extralit-Api-Key"] == "env-key" + + +async def test_credentials_file_fallback(monkeypatch, tmp_path): + monkeypatch.delenv("EXTRALIT_API_URL", raising=False) + monkeypatch.delenv("EXTRALIT_API_KEY", raising=False) + creds = tmp_path / "credentials.json" + creds.write_text(json.dumps({"api_url": API, "api_key": "file-key"})) + import extralit.client.login as login_mod + + monkeypatch.setattr(login_mod, "EXTRALIT_CREDENTIALS_FILE", creds) + client = AsyncClient() + assert client._transport._api_key == "file-key" + await client.aclose() + + +async def test_unresolvable_raises(monkeypatch): + monkeypatch.delenv("EXTRALIT_API_URL", raising=False) + monkeypatch.delenv("EXTRALIT_API_KEY", raising=False) + import extralit.client.login as login_mod + + monkeypatch.setattr(login_mod.ExtralitCredentials, "exists", classmethod(lambda cls: False)) + with pytest.raises(ValueError, match="api_url"): + AsyncClient() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/v2/test_client.py -v --disable-warnings` +Expected: FAIL — `ImportError: cannot import name 'AsyncClient'` + +- [ ] **Step 3: Implement** + +`src/extralit/v2/client.py`: + +```python +from __future__ import annotations + +import os +from typing import Optional + +from extralit.v2._api._transport import AsyncTransport +from extralit.v2.resources import Projections, Questions, Records, Responses, Schemas, Suggestions + + +def _credentials_fallback() -> tuple: + # The single, documented v1 import: the credentials file outlives v1 retirement. + from extralit.client.login import ExtralitCredentials + + if not ExtralitCredentials.exists(): + return None, None + try: + credentials = ExtralitCredentials.load() + return credentials.api_url, credentials.api_key + except (OSError, KeyError, ValueError): + return None, None + + +class AsyncClient: + """Async-native /api/v2 client. Resolution order for connection settings: + explicit args > EXTRALIT_API_URL / EXTRALIT_API_KEY env > ~/.extralit/credentials.json.""" + + def __init__( + self, + api_url: Optional[str] = None, + api_key: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + timeout: float = 60.0, + retries: int = 5, + ): + api_url = api_url or os.environ.get("EXTRALIT_API_URL") + api_key = api_key or os.environ.get("EXTRALIT_API_KEY") + if not api_url or (not api_key and not username): + file_url, file_key = _credentials_fallback() + api_url = api_url or file_url + if not api_key and not username: + api_key = file_key + if not api_url: + raise ValueError("api_url is required (argument, EXTRALIT_API_URL, or ~/.extralit/credentials.json)") + if not api_key and not username: + raise ValueError("credentials required: api_key or username/password") + self._transport = AsyncTransport( + api_url, api_key=api_key, username=username, password=password, timeout=timeout, retries=retries + ) + self.schemas = Schemas(self._transport) + self.questions = Questions(self._transport) + self.records = Records(self._transport) + self.suggestions = Suggestions(self._transport, self.questions) + self.projections = Projections(self._transport) + self.responses = Responses(self._transport) + + async def aclose(self) -> None: + await self._transport.aclose() + + async def __aenter__(self) -> "AsyncClient": + return self + + async def __aexit__(self, *exc_info) -> None: + await self.aclose() +``` + +`src/extralit/v2/__init__.py`: + +```python +from extralit.v2.client import AsyncClient +from extralit.v2.models import ( + ProjectionCell, + ProjectionRecord, + ProjectionView, + Question, + Record, + ReferenceGroup, + ReferenceView, + Response, + Schema, + SchemaVersion, + SearchPage, + Suggestion, +) + +__all__ = [ + "AsyncClient", + "ProjectionCell", "ProjectionRecord", "ProjectionView", "Question", "Record", + "ReferenceGroup", "ReferenceView", "Response", "Schema", "SchemaVersion", + "SearchPage", "Suggestion", +] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/v2/test_client.py -v --disable-warnings` +Expected: 4 PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/extralit/v2/client.py src/extralit/v2/__init__.py tests/unit/v2/test_client.py +git commit -m "feat(sdk-v2): AsyncClient assembly with env and credentials-file fallback" +``` + +--- + +### Task 10: Sync facade — portal + mechanical mirrors + +**Files:** +- Create: `src/extralit/v2/_sync.py`, `tests/unit/v2/test_sync_client.py` +- Modify: `src/extralit/v2/__init__.py` + +**Interfaces:** +- Consumes: `AsyncClient` (Task 9), resources. +- Produces: + - `class Client` — same constructor signature as `AsyncClient`; exposes the same resource attributes where every async method is a sync mirror (dispatched to a background-thread event loop, so it works inside Jupyter where a loop is already running). `def close()`, context-manager support. + - Export `Client` from `extralit.v2`. + +- [ ] **Step 1: Write the failing tests** + +`tests/unit/v2/test_sync_client.py`: + +```python +import asyncio + +from extralit.v2 import Client + +API = "http://test:6900" + + +def test_sync_mirror_calls_through(httpx_mock): + httpx_mock.add_response(url=f"{API}/api/v2/schemas?workspace_id=w", json={"items": []}) + with Client(api_url=API, api_key="k") as client: + assert client.schemas.list("w") == [] + + +def test_sync_client_works_inside_running_loop(httpx_mock): + """Jupyter simulation: a loop is already running in the calling thread. + asyncio.run-based facades explode here; the portal must not.""" + httpx_mock.add_response(url=f"{API}/api/v2/schemas?workspace_id=w", json={"items": []}) + + async def main(): + with Client(api_url=API, api_key="k") as client: + return client.schemas.list("w") + + assert asyncio.run(main()) == [] + + +def test_non_coroutine_attrs_pass_through(httpx_mock): + with Client(api_url=API, api_key="k") as client: + client.questions.invalidate("some-schema") # sync method on a resource: plain passthrough +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/v2/test_sync_client.py -v --disable-warnings` +Expected: FAIL — `ImportError: cannot import name 'Client'` + +- [ ] **Step 3: Implement** + +`src/extralit/v2/_sync.py`: + +```python +from __future__ import annotations + +import asyncio +import functools +import inspect +import threading + +from extralit.v2.client import AsyncClient +from extralit.v2.resources._base import ResourceBase + +_RESOURCE_NAMES = ("schemas", "questions", "records", "suggestions", "projections", "responses") + + +class _Portal: + """A background-thread event loop. Sync mirrors submit coroutines here, so they + work even when the calling thread already runs a loop (Jupyter).""" + + def __init__(self): + self._loop = asyncio.new_event_loop() + self._thread = threading.Thread(target=self._run, name="extralit-v2-portal", daemon=True) + self._thread.start() + + def _run(self) -> None: + asyncio.set_event_loop(self._loop) + self._loop.run_forever() + + def run(self, coroutine): + return asyncio.run_coroutine_threadsafe(coroutine, self._loop).result() + + def stop(self) -> None: + self._loop.call_soon_threadsafe(self._loop.stop) + self._thread.join() + + +class _SyncProxy: + """Wraps a resource: coroutine methods become sync calls through the portal; + everything else passes through unchanged. Mirrors are mechanical — never hand-written.""" + + def __init__(self, target: ResourceBase, portal: _Portal): + self._target = target + self._portal = portal + + def __getattr__(self, name: str): + attribute = getattr(self._target, name) + if inspect.iscoroutinefunction(attribute): + @functools.wraps(attribute) + def call(*args, **kwargs): + return self._portal.run(attribute(*args, **kwargs)) + + return call + return attribute + + +class Client: + """Sync facade over AsyncClient — same constructor, same resource surface.""" + + def __init__(self, *args, **kwargs): + self._portal = _Portal() + self._async = AsyncClient(*args, **kwargs) + for name in _RESOURCE_NAMES: + setattr(self, name, _SyncProxy(getattr(self._async, name), self._portal)) + + def close(self) -> None: + self._portal.run(self._async.aclose()) + self._portal.stop() + + def __enter__(self) -> "Client": + return self + + def __exit__(self, *exc_info) -> None: + self.close() +``` + +Add to `src/extralit/v2/__init__.py` imports/`__all__`: + +```python +from extralit.v2._sync import Client +``` + +(and `"Client"` in `__all__`.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/v2/test_sync_client.py -v --disable-warnings` +Expected: 3 PASS. Also rerun the whole v2 suite: `uv run pytest tests/unit/v2 -v --disable-warnings` — all green. + +- [ ] **Step 5: Commit** + +```bash +git add src/extralit/v2/_sync.py src/extralit/v2/__init__.py tests/unit/v2/test_sync_client.py +git commit -m "feat(sdk-v2): sync facade via background-thread portal" +``` + +--- + +### Task 11: CLI plumbing — JSON-first output, error contract, client context + +**Files:** +- Create: `src/extralit/v2/cli/__init__.py`, `src/extralit/v2/cli/_output.py`, `src/extralit/v2/cli/_context.py`, `tests/unit/v2/cli/__init__.py`, `tests/unit/v2/cli/test_output.py` + +**Interfaces:** +- Consumes: `Client` (Task 10), error hierarchy (Task 2). +- Produces: + - `_output.emit(data, json_flag: bool) -> None` — pydantic models/lists/dicts → JSON on stdout when `json_flag` **or stdout is not a TTY**; Rich table/pretty otherwise. + - `_output.fail(error: Exception) -> NoReturn` — `{"error": {"type", "status", "detail"}}` JSON on **stderr**; exits 3 for `ValidationError`, 1 otherwise. + - `_output.handle_errors(fn)` — decorator wrapping a command body: catches `V2APIError` → `fail`. + - `_context.get_client() -> Client` — env/credentials-file resolution (delegated to `Client()`); config errors exit 1 with the same stderr JSON shape. + - Exit-code contract: 0 success, 1 API/config error, 2 usage (typer's default), 3 validation. + +- [ ] **Step 1: Write the failing tests** + +`tests/unit/v2/cli/test_output.py`: + +```python +import json + +import pytest +import typer + +from extralit.v2._api._errors import V2APIError, ValidationError +from extralit.v2.cli._output import emit, fail, to_jsonable +from extralit.v2.models import SearchPage + + +def test_to_jsonable_handles_models_lists_dicts(): + page = SearchPage(items=[], total=3) + assert to_jsonable(page) == {"items": [], "total": 3} + assert to_jsonable([page]) == [{"items": [], "total": 3}] + assert to_jsonable({"a": 1}) == {"a": 1} + + +def test_emit_json_when_flag_set(capsys): + emit({"a": 1}, json_flag=True) + assert json.loads(capsys.readouterr().out) == {"a": 1} + + +def test_emit_json_when_not_a_tty(capsys): + emit({"a": 1}, json_flag=False) # pytest capture is not a tty -> auto-JSON + assert json.loads(capsys.readouterr().out) == {"a": 1} + + +def test_fail_validation_exits_3_with_stderr_json(capsys): + with pytest.raises(typer.Exit) as excinfo: + fail(ValidationError(422, "bad value")) + assert excinfo.value.exit_code == 3 + err = json.loads(capsys.readouterr().err) + assert err["error"]["status"] == 422 and err["error"]["type"] == "ValidationError" + + +def test_fail_api_error_exits_1(capsys): + with pytest.raises(typer.Exit) as excinfo: + fail(V2APIError(500, "kaboom")) + assert excinfo.value.exit_code == 1 + assert json.loads(capsys.readouterr().err)["error"]["detail"] == "kaboom" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/v2/cli/test_output.py -v --disable-warnings` +Expected: FAIL — `ModuleNotFoundError: No module named 'extralit.v2.cli'` + +- [ ] **Step 3: Implement** + +`src/extralit/v2/cli/_output.py`: + +```python +from __future__ import annotations + +import functools +import json +import sys +from typing import Any + +import typer + +from extralit.v2._api._errors import V2APIError, ValidationError + + +def to_jsonable(data: Any) -> Any: + if hasattr(data, "model_dump"): + return data.model_dump(mode="json") + if isinstance(data, list): + return [to_jsonable(item) for item in data] + if isinstance(data, dict): + return {key: to_jsonable(value) for key, value in data.items()} + return data + + +def emit(data: Any, json_flag: bool) -> None: + """JSON-first: --json forces JSON; a non-TTY stdout (pipes, agents, CI) defaults to it. + Humans at a terminal get Rich output.""" + if json_flag or not sys.stdout.isatty(): + typer.echo(json.dumps(to_jsonable(data), default=str)) + return + from rich.console import Console # lazy: JSON path must not pay for rich + + Console().print(to_jsonable(data)) + + +def fail(error: Exception) -> None: + status = getattr(error, "status_code", None) + payload = {"error": {"type": type(error).__name__, "status": status, "detail": str(getattr(error, "detail", error))}} + typer.echo(json.dumps(payload, default=str), err=True) + raise typer.Exit(code=3 if isinstance(error, ValidationError) else 1) + + +def handle_errors(fn): + @functools.wraps(fn) + def wrapper(*args, **kwargs): + try: + return fn(*args, **kwargs) + except V2APIError as error: + fail(error) + + return wrapper +``` + +`src/extralit/v2/cli/_context.py`: + +```python +from __future__ import annotations + +from extralit.v2._sync import Client +from extralit.v2.cli._output import fail + + +def get_client() -> Client: + """Non-interactive by construction: args come from env or the credentials file; + a missing configuration is a structured error, never a prompt.""" + try: + return Client() + except ValueError as error: + fail(error) + raise # unreachable; keeps type-checkers happy +``` + +`src/extralit/v2/cli/__init__.py` (placeholder for now; Task 12 fills in `add_v2_commands`): + +```python +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/v2/cli/test_output.py -v --disable-warnings` +Expected: 5 PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/extralit/v2/cli tests/unit/v2/cli +git commit -m "feat(sdk-v2): CLI JSON-first output helpers and error contract" +``` + +--- + +### Task 12: CLI `schemas` verbs + top-level takeover in app.py + +**Files:** +- Create: `src/extralit/v2/cli/schemas.py`, `tests/unit/v2/cli/test_cli_schemas.py` +- Modify: `src/extralit/v2/cli/__init__.py`, `src/extralit/cli/app.py` + +**Interfaces:** +- Consumes: `get_client`, `emit`, `handle_errors` (Task 11); `Client.schemas` methods (Tasks 5/10). +- Produces: + - `extralit.v2.cli.schemas.app` — typer app with commands `list`, `get`, `create`, `publish`, `versions`. + - `extralit.v2.cli.add_v2_commands(app: typer.Typer) -> None` — registers all v2 verb groups at top level (this task: `schemas`; Task 13/14 extend it). + - `cli/app.py` no longer registers the v1 `schemas` subcommand; it calls `add_v2_commands(app)` instead. + +- [ ] **Step 1: Check v1 schemas CLI is safe to unregister** + +```bash +grep -rn "cli.schemas\|cli import schemas\|from extralit.cli.schemas" src/ tests/ --include="*.py" | grep -v "src/extralit/cli/schemas/" +``` + +Expected: hits only in `src/extralit/cli/app.py` and old tests (`tests/unit/cli/test_cli_schemas.py`). If another module (e.g. `cli/extraction`) imports from `extralit.cli.schemas`, keep the module files (we only unregister the subcommand) — that is the default posture anyway: **unregister, don't delete**. + +- [ ] **Step 2: Write the failing tests** + +`tests/unit/v2/cli/test_cli_schemas.py`: + +```python +import json +import uuid +from datetime import datetime, timezone +from types import SimpleNamespace + +import pytest +from typer.testing import CliRunner + +import extralit.v2.cli._context as context_mod +from extralit.v2._api._errors import ValidationError +from extralit.v2.cli.schemas import app +from extralit.v2.models import Schema + +runner = CliRunner() # click >= 8.2: stderr is separated by default (mix_stderr was removed) +WS = str(uuid.uuid4()) +NOW = datetime.now(timezone.utc).isoformat() + + +def _schema(name="trials"): + return Schema.model_validate( + { + "id": str(uuid.uuid4()), "name": name, "status": "draft", "current_version_id": None, + "settings": {}, "workspace_id": WS, "inserted_at": NOW, "updated_at": NOW, + } + ) + + +class FakeClient(SimpleNamespace): + def __enter__(self): + return self + + def __exit__(self, *exc_info): + pass + + +@pytest.fixture +def fake_client(monkeypatch): + client = FakeClient(schemas=SimpleNamespace(list=lambda workspace_id: [_schema()])) + monkeypatch.setattr(context_mod, "get_client", lambda: client) + # schemas.py imports get_client at call time via the module attribute: + import extralit.v2.cli.schemas as schemas_mod + + monkeypatch.setattr(schemas_mod, "get_client", lambda: client) + return client + + +def test_list_emits_json(fake_client): + result = runner.invoke(app, ["list", "--workspace-id", WS, "--json"]) + assert result.exit_code == 0 + items = json.loads(result.stdout) + assert items[0]["name"] == "trials" + + +def test_validation_error_exits_3(fake_client): + def boom(workspace_id): + raise ValidationError(422, "bad") + + fake_client.schemas.list = boom + result = runner.invoke(app, ["list", "--workspace-id", WS, "--json"]) + assert result.exit_code == 3 + assert json.loads(result.stderr)["error"]["type"] == "ValidationError" + + +def test_top_level_registration(): + from extralit.cli.app import app as root_app + + names = [t.name for t in root_app.registered_groups] + # Task 14 Step 4 extends this tuple to all six verbs once they exist: + # ("schemas", "records", "questions", "suggestions", "projection", "references") + for verb in ("schemas",): + assert verb in names, f"{verb} not registered at top level" + assert names.count("schemas") == 1, "v1 schemas subcommand must be unregistered" +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/v2/cli/test_cli_schemas.py -v --disable-warnings` +Expected: FAIL — `ModuleNotFoundError: No module named 'extralit.v2.cli.schemas'` + +- [ ] **Step 4: Implement** + +`src/extralit/v2/cli/schemas.py`: + +```python +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional +from uuid import UUID + +import typer + +from extralit.v2.cli._context import get_client +from extralit.v2.cli._output import emit, handle_errors + +app = typer.Typer(help="Manage v2 schemas (Pandera, versioned)", no_args_is_help=True) + +JSON_FLAG = typer.Option(False, "--json", help="Force JSON output (auto when stdout is not a TTY)") + + +@app.command("list") +@handle_errors +def list_schemas( + workspace_id: str = typer.Option(..., "--workspace-id", help="Workspace UUID"), + json_flag: bool = JSON_FLAG, +): + with get_client() as client: + emit(client.schemas.list(UUID(workspace_id)), json_flag) + + +@app.command("get") +@handle_errors +def get_schema(schema_id: str = typer.Argument(...), json_flag: bool = JSON_FLAG): + with get_client() as client: + emit(client.schemas.get(UUID(schema_id)), json_flag) + + +@app.command("create") +@handle_errors +def create_schema( + name: str = typer.Argument(...), + workspace_id: str = typer.Option(..., "--workspace-id"), + settings: Optional[str] = typer.Option(None, "--settings", help="Settings as a JSON object"), + json_flag: bool = JSON_FLAG, +): + with get_client() as client: + emit( + client.schemas.create(UUID(workspace_id), name, settings=json.loads(settings) if settings else None), + json_flag, + ) + + +@app.command("publish") +@handle_errors +def publish_version( + schema_id: str = typer.Argument(...), + file: Path = typer.Option(..., "--file", help="Pandera DataFrameSchema JSON (schema.to_json() output)"), + review_widgets: Optional[str] = typer.Option(None, "--review-widgets", help="JSON: {column: widget config}"), + json_flag: bool = JSON_FLAG, +): + with get_client() as client: + emit( + client.schemas.publish( + UUID(schema_id), + file.read_text(), + review_widgets=json.loads(review_widgets) if review_widgets else None, + ), + json_flag, + ) + + +@app.command("versions") +@handle_errors +def list_versions(schema_id: str = typer.Argument(...), json_flag: bool = JSON_FLAG): + with get_client() as client: + emit(client.schemas.versions(UUID(schema_id)), json_flag) +``` + +`src/extralit/v2/cli/__init__.py`: + +```python +from __future__ import annotations + +import typer + + +def add_v2_commands(app: typer.Typer) -> None: + """Register v2 verbs at the TOP level of the extralit CLI (no `v2` prefix). + v2 owns these names; the v1 `schemas` subcommand is deliberately replaced.""" + from extralit.v2.cli import schemas + + app.add_typer(schemas.app, name="schemas") +``` + +Modify `src/extralit/cli/app.py` — remove `schemas` from the v1 import list and its registration, add the v2 call: + +```python +# In the import block at the top, DELETE the `schemas,` line: +from extralit.cli import ( + datasets, + documents, + extraction, + files, + info, + login, + logout, + training, + users, + whoami, + workflows, + workspaces, +) + + +# In register_subcommands(), DELETE `app.add_typer(schemas.app, name="schemas")` and append: +def register_subcommands(): + app.add_typer(datasets.app, name="datasets") + app.add_typer(documents.app, name="documents") + app.add_typer(extraction.app, name="extraction") + app.add_typer(files.app, name="files", hidden=True) + app.add_typer(info.app, name="info") + app.add_typer(login.app, name="login") + app.add_typer(logout.app, name="logout") + app.add_typer(training.app, name="training") + app.add_typer(users.app, name="users") + app.add_typer(whoami.app, name="whoami") + app.add_typer(workflows.app, name="workflows") + app.add_typer(workspaces.app, name="workspaces") + + # v2 owns the top-level verbs: schemas, records, questions, suggestions, projection, references. + from extralit.v2.cli import add_v2_commands # composition-root exception to the v1/v2 wall + + add_v2_commands(app) +``` + +Also delete or update the old v1 CLI test that asserts the v1 schemas commands exist (`tests/unit/cli/test_cli_schemas.py`): delete the file — the v1 subcommand is retired by design (spec §5). + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/v2/cli/test_cli_schemas.py tests/unit/cli -v --disable-warnings` +Expected: new tests PASS; remaining v1 CLI tests PASS (no schemas registration asserted anywhere). + +- [ ] **Step 6: Commit** + +```bash +git add src/extralit/v2/cli src/extralit/cli/app.py tests/unit/v2/cli tests/unit/cli +git commit -m "feat(sdk-v2): top-level schemas CLI verbs replace v1 schemas subcommand" +``` + +--- + +### Task 13: CLI `records` and `questions` verbs (JSONL stdin piping) + +**Files:** +- Create: `src/extralit/v2/cli/records.py`, `src/extralit/v2/cli/questions.py`, `tests/unit/v2/cli/test_cli_records.py` +- Modify: `src/extralit/v2/cli/__init__.py` + +**Interfaces:** +- Consumes: Task 11 plumbing; `Client.records`/`Client.questions`. +- Produces: + - `records` verbs: `upsert SCHEMA_ID [--file PATH|-] [--reference R]` (reads JSONL: one item per line), `search SCHEMA_ID [--text T] [--filter col:op:value ...] [--offset N] [--limit N]`, `list SCHEMA_ID [--status S] [--reference R] [--offset N] [--limit N]`, `delete SCHEMA_ID --ids id1,id2,...` + - `questions` verbs: `list SCHEMA_ID` + - `--filter` value syntax: `column:op:value` where `value` is JSON-decoded when possible (so `age:ge:18` → int 18; `label:in:["a","b"]` → list) + +- [ ] **Step 1: Write the failing tests** + +`tests/unit/v2/cli/test_cli_records.py`: + +```python +import json +import uuid +from datetime import datetime, timezone +from types import SimpleNamespace + +import pytest +from typer.testing import CliRunner + +from extralit.v2.cli.records import _parse_filter, app +from extralit.v2.models import Record, SearchPage + +runner = CliRunner() # click >= 8.2: stderr is separated by default (mix_stderr was removed) +SCHEMA_ID = str(uuid.uuid4()) +NOW = datetime.now(timezone.utc).isoformat() + + +def _record(): + return Record.model_validate( + { + "id": str(uuid.uuid4()), "schema_id": SCHEMA_ID, "schema_version_id": str(uuid.uuid4()), + "reference": "10.1000/xyz", "external_id": None, "fields": {"size": "120"}, + "metadata": None, "status": "pending", "inserted_at": NOW, "updated_at": NOW, + } + ) + + +class FakeClient(SimpleNamespace): + def __enter__(self): + return self + + def __exit__(self, *exc_info): + pass + + +@pytest.fixture +def fake_client(monkeypatch): + calls = {} + + def upsert(schema_id, items, reference=None): + calls["upsert"] = (schema_id, items, reference) + return [_record()] + + def search(schema_id, text=None, filters=None, offset=0, limit=50): + calls["search"] = (schema_id, text, filters, offset, limit) + return SearchPage(items=[_record()], total=1) + + client = FakeClient(records=SimpleNamespace(bulk_upsert=upsert, search=search)) + import extralit.v2.cli.records as records_mod + + monkeypatch.setattr(records_mod, "get_client", lambda: client) + return calls + + +def test_parse_filter_json_decodes_value(): + assert _parse_filter("age:ge:18") == ("age", "ge", 18) + assert _parse_filter('label:in:["a","b"]') == ("label", "in", ["a", "b"]) + assert _parse_filter("country:eq:KE") == ("country", "eq", "KE") + + +def test_upsert_reads_jsonl_from_stdin(fake_client): + lines = '{"size": "120"}\n{"size": "135"}\n' + result = runner.invoke(app, ["upsert", SCHEMA_ID, "--reference", "10.1000/xyz"], input=lines) + assert result.exit_code == 0, result.stderr + schema_id, items, reference = fake_client["upsert"] + assert items == [{"size": "120"}, {"size": "135"}] + assert reference == "10.1000/xyz" + assert json.loads(result.stdout)[0]["fields"] == {"size": "120"} + + +def test_search_passes_filters(fake_client): + result = runner.invoke( + app, ["search", SCHEMA_ID, "--text", "tumor", "--filter", "age:ge:18", "--limit", "10"] + ) + assert result.exit_code == 0 + _, text, filters, _, limit = fake_client["search"] + assert text == "tumor" and filters == [("age", "ge", 18)] and limit == 10 + assert json.loads(result.stdout)["total"] == 1 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/v2/cli/test_cli_records.py -v --disable-warnings` +Expected: FAIL — `ModuleNotFoundError: No module named 'extralit.v2.cli.records'` + +- [ ] **Step 3: Implement** + +`src/extralit/v2/cli/records.py`: + +```python +from __future__ import annotations + +import json +import sys +from typing import Optional +from uuid import UUID + +import typer + +from extralit.v2.cli._context import get_client +from extralit.v2.cli._output import emit, handle_errors + +app = typer.Typer(help="Manage v2 records (schema-version-pinned, reference-keyed)", no_args_is_help=True) + +JSON_FLAG = typer.Option(False, "--json", help="Force JSON output (auto when stdout is not a TTY)") + + +def _parse_filter(raw: str) -> tuple: + """col:op:value — value is JSON-decoded when possible ('age:ge:18' -> int 18).""" + column, op, value = raw.split(":", 2) + try: + value = json.loads(value) + except ValueError: + pass # keep as string + return (column, op, value) + + +def _read_jsonl(file: Optional[str]) -> list[dict]: + stream = sys.stdin if file in (None, "-") else open(file) + try: + return [json.loads(line) for line in stream if line.strip()] + finally: + if stream is not sys.stdin: + stream.close() + + +@app.command("upsert") +@handle_errors +def upsert_records( + schema_id: str = typer.Argument(...), + file: Optional[str] = typer.Option(None, "--file", help="JSONL file of items; '-' or omitted reads stdin"), + reference: Optional[str] = typer.Option(None, "--reference", help="Reference applied to items lacking one"), + json_flag: bool = JSON_FLAG, +): + items = _read_jsonl(file) + with get_client() as client: + emit(client.records.bulk_upsert(UUID(schema_id), items, reference=reference), json_flag) + + +@app.command("search") +@handle_errors +def search_records( + schema_id: str = typer.Argument(...), + text: Optional[str] = typer.Option(None, "--text"), + filters: list[str] = typer.Option([], "--filter", help="col:op:value (op: eq|in|ge|le); repeatable"), + offset: int = typer.Option(0, "--offset"), + limit: int = typer.Option(50, "--limit"), + json_flag: bool = JSON_FLAG, +): + with get_client() as client: + emit( + client.records.search( + UUID(schema_id), + text=text, + filters=[_parse_filter(raw) for raw in filters], + offset=offset, + limit=limit, + ), + json_flag, + ) + + +@app.command("list") +@handle_errors +def list_records( + schema_id: str = typer.Argument(...), + status: Optional[str] = typer.Option(None, "--status"), + reference: Optional[str] = typer.Option(None, "--reference"), + offset: int = typer.Option(0, "--offset"), + limit: int = typer.Option(50, "--limit"), + json_flag: bool = JSON_FLAG, +): + with get_client() as client: + emit( + client.records.list(UUID(schema_id), status=status, reference=reference, offset=offset, limit=limit), + json_flag, + ) + + +@app.command("delete") +@handle_errors +def delete_records( + schema_id: str = typer.Argument(...), + ids: str = typer.Option(..., "--ids", help="Comma-separated record ids"), + json_flag: bool = JSON_FLAG, +): + with get_client() as client: + client.records.delete(UUID(schema_id), ids.split(",")) + emit({"deleted": len(ids.split(","))}, json_flag) +``` + +`src/extralit/v2/cli/questions.py`: + +```python +from __future__ import annotations + +from uuid import UUID + +import typer + +from extralit.v2.cli._context import get_client +from extralit.v2.cli._output import emit, handle_errors + +app = typer.Typer(help="Inspect v2 questions (column-bound)", no_args_is_help=True) + +JSON_FLAG = typer.Option(False, "--json", help="Force JSON output (auto when stdout is not a TTY)") + + +@app.command("list") +@handle_errors +def list_questions(schema_id: str = typer.Argument(...), json_flag: bool = JSON_FLAG): + with get_client() as client: + emit(client.questions.list(UUID(schema_id)), json_flag) +``` + +Extend `src/extralit/v2/cli/__init__.py` `add_v2_commands`: + +```python + from extralit.v2.cli import questions, records, schemas + + app.add_typer(schemas.app, name="schemas") + app.add_typer(records.app, name="records") + app.add_typer(questions.app, name="questions") +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/v2/cli/test_cli_records.py -v --disable-warnings` +Expected: 3 PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/extralit/v2/cli tests/unit/v2/cli/test_cli_records.py +git commit -m "feat(sdk-v2): records/questions CLI verbs with JSONL stdin piping" +``` + +--- + +### Task 14: CLI `suggestions`, `projection`, `references` verbs + +**Files:** +- Create: `src/extralit/v2/cli/suggestions.py`, `src/extralit/v2/cli/projection.py`, `src/extralit/v2/cli/references.py`, `tests/unit/v2/cli/test_cli_annotation.py` +- Modify: `src/extralit/v2/cli/__init__.py`, `tests/unit/v2/cli/test_cli_schemas.py` (extend registration assertion) + +**Interfaces:** +- Consumes: Task 11 plumbing; `Client.suggestions`/`Client.projections`/`Client.records.get_reference`. +- Produces: + - `suggestions upsert RECORD_ID (--question-id UUID | --question NAME --schema-id UUID) --value JSON [--score F] [--agent A]` + - `projection get REFERENCE --workspace-id UUID` + - `references get REFERENCE --workspace-id UUID` + - Final `add_v2_commands` registering all six verb groups. + +- [ ] **Step 1: Write the failing tests** + +`tests/unit/v2/cli/test_cli_annotation.py`: + +```python +import json +import uuid +from types import SimpleNamespace + +import pytest +from typer.testing import CliRunner + +from extralit.v2.cli.projection import app as projection_app +from extralit.v2.cli.suggestions import app as suggestions_app +from extralit.v2.models import ProjectionView + +runner = CliRunner() # click >= 8.2: stderr is separated by default (mix_stderr was removed) +RECORD_ID = str(uuid.uuid4()) +Q_ID = str(uuid.uuid4()) +WS = str(uuid.uuid4()) + + +class FakeClient(SimpleNamespace): + def __enter__(self): + return self + + def __exit__(self, *exc_info): + pass + + +def test_suggestions_upsert_decodes_json_value(monkeypatch): + calls = {} + + def upsert(record, question, value, score=None, agent=None, schema_id=None): + calls["args"] = (record, question, value, score, agent, schema_id) + return SimpleNamespace(model_dump=lambda mode: {"ok": True}) + + client = FakeClient(suggestions=SimpleNamespace(upsert=upsert)) + import extralit.v2.cli.suggestions as mod + + monkeypatch.setattr(mod, "get_client", lambda: client) + result = runner.invoke( + suggestions_app, + ["upsert", RECORD_ID, "--question-id", Q_ID, "--value", '"120"', "--score", "0.9", "--agent", "claude"], + ) + assert result.exit_code == 0, result.stderr + record, question, value, score, agent, schema_id = calls["args"] + assert (record, question, value, score, agent) == (RECORD_ID, Q_ID, "120", 0.9, "claude") + + +def test_suggestions_upsert_name_requires_schema_id(monkeypatch): + import extralit.v2.cli.suggestions as mod + + monkeypatch.setattr(mod, "get_client", lambda: FakeClient(suggestions=SimpleNamespace())) + result = runner.invoke(suggestions_app, ["upsert", RECORD_ID, "--question", "size", "--value", '"x"']) + assert result.exit_code == 2 # usage error: --question needs --schema-id + + +def test_projection_get(monkeypatch): + view = ProjectionView(reference="10.1000/j.abc", records=[], total_records=0) + client = FakeClient(projections=SimpleNamespace(get=lambda ws, ref: view)) + import extralit.v2.cli.projection as mod + + monkeypatch.setattr(mod, "get_client", lambda: client) + result = runner.invoke(projection_app, ["get", "10.1000/j.abc", "--workspace-id", WS]) + assert result.exit_code == 0 + assert json.loads(result.stdout)["reference"] == "10.1000/j.abc" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/v2/cli/test_cli_annotation.py -v --disable-warnings` +Expected: FAIL — `ModuleNotFoundError` + +- [ ] **Step 3: Implement** + +`src/extralit/v2/cli/suggestions.py`: + +```python +from __future__ import annotations + +import json +from typing import Optional +from uuid import UUID + +import typer + +from extralit.v2.cli._context import get_client +from extralit.v2.cli._output import emit, handle_errors + +app = typer.Typer(help="Write v2 suggestions (per record x question)", no_args_is_help=True) + +JSON_FLAG = typer.Option(False, "--json", help="Force JSON output (auto when stdout is not a TTY)") + + +@app.command("upsert") +@handle_errors +def upsert_suggestion( + record_id: str = typer.Argument(...), + question_id: Optional[str] = typer.Option(None, "--question-id", help="Question UUID"), + question: Optional[str] = typer.Option(None, "--question", help="Question NAME (needs --schema-id)"), + schema_id: Optional[str] = typer.Option(None, "--schema-id", help="Schema UUID for name resolution"), + value: str = typer.Option(..., "--value", help="Suggested value as JSON (e.g. '\"120\"' or '[1,2]')"), + score: Optional[float] = typer.Option(None, "--score"), + agent: Optional[str] = typer.Option(None, "--agent"), + json_flag: bool = JSON_FLAG, +): + if question_id is None and question is None: + raise typer.BadParameter("pass --question-id or --question") + if question is not None and question_id is None and schema_id is None: + raise typer.BadParameter("--question (a name) requires --schema-id to resolve against") + with get_client() as client: + emit( + client.suggestions.upsert( + record_id, + question_id or question, + json.loads(value), + score=score, + agent=agent, + schema_id=UUID(schema_id) if schema_id else None, + ), + json_flag, + ) +``` + +`src/extralit/v2/cli/projection.py`: + +```python +from __future__ import annotations + +from uuid import UUID + +import typer + +from extralit.v2.cli._context import get_client +from extralit.v2.cli._output import emit, handle_errors + +app = typer.Typer(help="Read v2 projections (response-or-suggestion per question)", no_args_is_help=True) + +JSON_FLAG = typer.Option(False, "--json", help="Force JSON output (auto when stdout is not a TTY)") + + +@app.command("get") +@handle_errors +def get_projection( + reference: str = typer.Argument(..., help="Reference (DOI/URL/filename; slashes fine)"), + workspace_id: str = typer.Option(..., "--workspace-id"), + json_flag: bool = JSON_FLAG, +): + with get_client() as client: + emit(client.projections.get(UUID(workspace_id), reference), json_flag) +``` + +`src/extralit/v2/cli/references.py`: + +```python +from __future__ import annotations + +from uuid import UUID + +import typer + +from extralit.v2.cli._context import get_client +from extralit.v2.cli._output import emit, handle_errors + +app = typer.Typer(help="Cross-schema reference views", no_args_is_help=True) + +JSON_FLAG = typer.Option(False, "--json", help="Force JSON output (auto when stdout is not a TTY)") + + +@app.command("get") +@handle_errors +def get_reference( + reference: str = typer.Argument(..., help="Reference (DOI/URL/filename; slashes fine)"), + workspace_id: str = typer.Option(..., "--workspace-id"), + json_flag: bool = JSON_FLAG, +): + with get_client() as client: + emit(client.records.get_reference(UUID(workspace_id), reference), json_flag) +``` + +Final `src/extralit/v2/cli/__init__.py`: + +```python +from __future__ import annotations + +import typer + + +def add_v2_commands(app: typer.Typer) -> None: + """Register v2 verbs at the TOP level of the extralit CLI (no `v2` prefix). + v2 owns these names; the v1 `schemas` subcommand is deliberately replaced.""" + from extralit.v2.cli import projection, questions, records, references, schemas, suggestions + + app.add_typer(schemas.app, name="schemas") + app.add_typer(records.app, name="records") + app.add_typer(questions.app, name="questions") + app.add_typer(suggestions.app, name="suggestions") + app.add_typer(projection.app, name="projection") + app.add_typer(references.app, name="references") +``` + +- [ ] **Step 4: Extend the registration assertion in `tests/unit/v2/cli/test_cli_schemas.py`** + +Update `test_top_level_registration` to loop over all six verbs: `("schemas", "records", "questions", "suggestions", "projection", "references")`. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/v2/cli -v --disable-warnings` +Expected: all PASS, including the six-verb registration assertion. + +- [ ] **Step 6: Commit** + +```bash +git add src/extralit/v2/cli tests/unit/v2/cli +git commit -m "feat(sdk-v2): suggestions/projection/references CLI verbs complete the verb set" +``` + +--- + +### Task 15: Boundary, lazy-import, and startup gates + docs + +**Files:** +- Create: `tests/unit/v2/test_boundaries.py` +- Modify: `extralit/CLAUDE.md` (component docs) + +**Interfaces:** +- Consumes: the whole v2 package. +- Produces: CI-enforced guarantees — import wall, no heavy imports at v2 import time, CLI startup sanity. + +- [ ] **Step 1: Write the failing tests** + +`tests/unit/v2/test_boundaries.py`: + +```python +import re +import subprocess +import sys +from pathlib import Path + +SRC = Path(__file__).parents[3] / "src" / "extralit" +V2 = SRC / "v2" + +# The single allowed v1 import inside v2 (credentials helper outlives v1 retirement). +ALLOWED_V1_IMPORT = "extralit.client.login" +V1_IMPORT = re.compile(r"^\s*(?:from|import)\s+(extralit\.(?!v2\b)[\w.]*)", re.MULTILINE) +V2_IMPORT = re.compile(r"^\s*(?:from|import)\s+extralit\.v2[\w.]*", re.MULTILINE) + + +def test_v2_imports_no_v1_except_credentials(): + violations = [] + for path in V2.rglob("*.py"): + if path.name == "_generated.py": + continue + for match in V1_IMPORT.finditer(path.read_text()): + if match.group(1) != ALLOWED_V1_IMPORT: + violations.append(f"{path.relative_to(SRC)}: {match.group(0).strip()}") + assert not violations, f"v2 -> v1 imports outside the credentials exception:\n" + "\n".join(violations) + + +def test_v1_never_imports_v2_except_composition_root(): + violations = [] + for path in SRC.rglob("*.py"): + if V2 in path.parents or path == SRC / "cli" / "app.py": + continue + if V2_IMPORT.search(path.read_text()): + violations.append(str(path.relative_to(SRC))) + assert not violations, f"v1 files importing v2 (only cli/app.py may):\n" + "\n".join(violations) + + +HEAVY = ("pandas", "pandera", "datasets", "huggingface_hub") + + +def _heavy_after(imports: str) -> set: + code = ( + f"import sys; {imports}; " + f"print(','.join(sorted(m for m in {HEAVY!r} if m in sys.modules)))" + ) + proc = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, check=True) + return set(filter(None, proc.stdout.strip().split(","))) + + +def test_v2_adds_no_heavy_imports(): + """Agents make many short CLI calls: importing extralit.v2 (incl. cli) must not add + heavy modules beyond what `import extralit` (the v1 package init) already drags in. + Delta-based on purpose: v1's own import weight is Phase 6 scope, measured at the + baseline (today: datasets + huggingface_hub via extralit/__init__.py).""" + baseline = _heavy_after("import extralit") + with_v2 = _heavy_after("import extralit; import extralit.v2; import extralit.v2.cli") + added = with_v2 - baseline + assert not added, f"extralit.v2 import added heavy modules: {sorted(added)}" +``` + +- [ ] **Step 2: Run tests** + +Run: `uv run pytest tests/unit/v2/test_boundaries.py -v --disable-warnings` +Expected: all 3 PASS immediately if Tasks 1–14 held the line; any failure names the violating file — fix it (usually a top-level heavy import that belongs inside a function). + +- [ ] **Step 3: Measure CLI startup and record the number** + +```bash +time uv run extralit schemas --help +uv run python -X importtime -c "import extralit.v2.cli" 2>&1 | tail -5 +``` + +The spec budget is < 300 ms for the v2 path. Note: `extralit.cli.app` still imports the v1 modules eagerly — if total startup exceeds the budget because of *v1* imports, record the measured split in the commit message; the v2 side must stay clean (the `test_v2_import_is_light` gate), and v1 startup is Phase 6 scope. + +- [ ] **Step 4: Full verification** + +```bash +uv run pytest tests/unit -v --disable-warnings +uv run ruff check +uv run ruff format --check +``` + +Expected: all green. + +- [ ] **Step 5: Update component docs** + +Append to `extralit/CLAUDE.md` a short v2 section: + +```markdown +## v2 SDK (`src/extralit/v2/`) + +- Parallel package for `/api/v2` (schema-centric). Import wall: v2 imports nothing from v1 + except `extralit.client.login`; only `cli/app.py` imports v2 (composition root). +- Wire types are GENERATED: `_api/openapi.json` (server `openapi-dump` snapshot) -> + `_api/_generated.py` via datamodel-codegen. Never hand-edit; regenerate with the command + in `tests/unit/v2/test_contract.py` and keep both in sync (drift-gated). +- `AsyncClient` is the real client; `Client` is a mechanical sync facade (background-thread + portal — works in Jupyter). CLI verbs register at TOP level (`extralit schemas|records|...`), + JSON-first (`--json` or non-TTY), errors as JSON on stderr (exit 0/1/2/3). +``` + +- [ ] **Step 6: Commit** + +```bash +git add tests/unit/v2/test_boundaries.py CLAUDE.md +git commit -m "test(sdk-v2): import-wall, lazy-import, and startup gates + docs" +``` + +--- + +## Deferred (from spec — do NOT implement in this plan) + +Responses write (submit/draft/discard), DataFrame/parquet/HF export, `rebuild-index`/admin verbs, webhooks, span questions, v1 retirement of the remaining subcommands, live-stack integration test (needs seeded backend — reuse `extralit-frontend/e2e/v2/seed/` approach in a follow-up). diff --git a/docs/superpowers/specs/2026-07-13-sdk-v2-redesign-design.md b/docs/superpowers/specs/2026-07-13-sdk-v2-redesign-design.md new file mode 100644 index 000000000..4f073f3b2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-sdk-v2-redesign-design.md @@ -0,0 +1,223 @@ +# Python SDK v2 — schema-centric client, agentic CLI, async performance + +**Date:** 2026-07-13 +**Status:** Approved design, pending implementation plan +**Companions:** `2026-06-27-schema-centric-data-model-design.md` (server model), +`2026-07-09-v2-frontend-vertical-slice-design.md` (frontend precedent this design mirrors) + +## Problem + +The backend grew a second data model (`/api/v2`: schema-centric, versioned, +projection-based) alongside the Argilla-lineage one (`/api/v1`). The Python SDK +(`extralit/`, v0.6.1) is hardcoded to `/api/v1`: sync-only `httpx.Client`, Argilla +entities (Dataset → Record → Question → Response), and a human-oriented Rich CLI. +It cannot drive the v2 extraction loop at all, and three of its properties block the +intended consumers: + +1. **Wrong model.** No client for schemas/versions, version-pinned records, + column-bound questions, reference grouping, or projections. +2. **Not agentic.** CLI output is Rich tables only — unparseable by agents; no + stable JSON, no meaningful exit-code contract, heavy imports make every + short-lived invocation slow. +3. **Sequential I/O.** All HTTP is blocking and serial; pushing large record sets + or fanning out per-record reads requires user-written loops. + +## Decision summary + +| Decision | Choice | +|---|---| +| Coexistence | Parallel `extralit.v2` package; v1 untouched; one wheel | +| Contract | Committed `openapi-dump` snapshot → generated Pydantic DTOs (Approach A) | +| Transport | Async-native (`httpx.AsyncClient`) + sync facade | +| CLI | Top-level verbs (`extralit schemas`, `extralit records`, …) — v2 owns the CLI surface; JSON-first, lazy-imported | +| Primary workflow | LLM extraction pipeline (publish schema → bulk-upsert → suggestions → projections) | +| Scope | Vertical slice (below); responses-write, exports, admin verbs deferred | + +Alternatives rejected: **evolve-in-place** (recreates the name-collision / +entity-bending trap the frontend explicitly avoided — a v2 `Record` is not a v1 +`Record`); **fully generated client** via `openapi-python-client` (generated +ergonomics fight the agentic-CLI and pipeline goals on every regen); **hand-written +wire models** (silent drift against a quirky contract we already have a dump tool for). + +## 1. Package architecture + +``` +src/extralit/v2/ +├── __init__.py # exports AsyncClient, Client, domain classes +├── _api/ +│ ├── openapi.json # committed snapshot (server `openapi-dump`) +│ ├── _generated.py # datamodel-code-generator → Pydantic v2 DTOs +│ ├── _transport.py # httpx.AsyncClient wrapper: bearer auth + refresh, retries +│ └── _errors.py # error hierarchy + 422 normalizer +├── _sync.py # sync facade (background-thread event-loop portal) +├── models/ # hand-written domain classes (Schema, SchemaVersion, Record, …) +├── resources/ # Schemas, Records, Questions, Suggestions, Projections +└── cli/ # top-level CLI verbs (lazy-registered into cli/app.py) +``` + +Python namespacing removes the frontend's need for `V2`-prefixed names: +`extralit.v2.Record` cannot collide with `extralit.Record`. Entry points are +explicit — `from extralit.v2 import Client` (sync) / `AsyncClient` — never re-exported +from the top-level `extralit` namespace. + +**Import wall (reuse-don't-fork, with a hard boundary).** `extralit.v2` imports +nothing from v1 modules, with one documented exception: a shared credentials helper +(env vars + `~/.extralit/credentials.json`), which survives v1 retirement anyway. +v1 never imports v2. The boundary is grep-checkable and gated in CI. Phase 6 +retirement deletes v1 packages mechanically. `cli/app.py` is the composition root +(like the frontend's `plugins/3.di.ts`): it may import `extralit.v2.cli` to register +commands without counting as a boundary violation. + +## 2. Contract layer + +`_api/openapi.json` is a checked-in dump of the server's v2 OpenAPI schema, produced +by the existing server CLI (`uv run python -m extralit_server openapi-dump`). +`datamodel-code-generator` (dev dependency, `--output-model-type pydantic_v2.BaseModel`) +turns it into `_generated.py`. Resources type every wire payload from generated DTOs; +hand-written shapes never touch the wire. + +Two CI gates, mirroring the frontend's: + +- **No-drift:** regenerate from the committed snapshot and diff; any change fails. +- **Snapshot-vs-server:** the committed snapshot must match a fresh `openapi-dump` + from the server tree at the same monorepo revision. + +## 3. Transport & performance + +- **Async-native.** One `httpx.AsyncClient` per client instance: pooled connections, + transport `retries=5`, 60s default timeout — matching v1's proven settings. +- **Auth.** v2 routes accept both backends (verified: `security/authentication/ + provider.py` registers `APIKeyAuthenticationBackend` and + `BearerTokenAuthenticationBackend` on `get_current_user`). The SDK default is the + API-key header — same credentials as v1, no token lifecycle. Bearer JWT via + `POST /api/v2/token` (username/password, 30-min access / 30-day refresh) is also + supported; the transport transparently refreshes once on 401, then re-raises. +- **Sync facade.** `Client` mirrors every `AsyncClient` method by dispatching to a + background-thread event loop (a "portal"), not `asyncio.run` — so sync usage works + inside Jupyter, where a loop is already running. Sync mirrors are generated + mechanically from the async resource surface, not hand-maintained. +- **Bulk pipeline.** `records.bulk_upsert()` auto-chunks at the server's 500-item + cap and dispatches chunks with bounded concurrency (default semaphore of 4, + configurable) plus an optional progress callback. Deletes chunk at the 100-id cap. + Pushing 50k records is one SDK call. +- **Immutable-data caching.** Schema versions are immutable → cached in-client by + `(schema_id, version)`. Question name↔id maps cached per schema, invalidated on + question writes through the same client. + +## 4. Domain resources — the extraction loop + +The resource layer is the anti-corruption layer: every wire quirk lives here once, +so nothing above it knows about `snake_case` bodies, double-wrapping, or id-vs-name +keying. + +```python +async with AsyncClient(api_url=..., api_key=...) as xl: + schema = await xl.schemas.get_by_name(workspace_id, "clinical_trials") + version = await xl.schemas.publish(schema.id, pandera_schema, review_widgets={...}) + await xl.records.bulk_upsert(schema.id, rows, reference="10.1000/xyz") # auto-chunked + page = await xl.records.search(schema.id, text="tumor size", + filters=[("patient_age", "ge", 18)]) + await xl.suggestions.upsert(record.id, question="dosage", value=..., score=0.9, + agent="claude") + proj = await xl.projections.get(workspace_id, reference="10.1000/xyz") +``` + +Resources and the quirks they encapsulate (all confirmed against the server and the +frontend's PR #230 findings): + +- **Schemas** — create/get/list/update; `publish()` posts a Pandera body plus + out-of-band `review_widgets` (Pandera's `to_json()` drops column metadata); + versions list/get; `columns()` from the current version's cache. +- **Records** — `bulk_upsert` (idempotent on `external_id`, `metadata` is + patch-like), `search` (text + `(column, op, value)` filters with + `op ∈ {eq,in,ge,le}`; `total` is approximate — stale index ids are skipped and + FTS saturates, so pagination never promises exact counts), `list`, `delete`, + and `references.get()` for the cross-schema reference view. `bulk_upsert` + accepts list-of-dicts or a pandas DataFrame (pandas imported lazily inside that + path only). +- **Questions** — list/get per schema. Callers address questions by **name**; + the resource resolves to **id** via the cached map (suggestions key by id; + cells and response values key by name — getting this wrong silently detaches + provenance). +- **Suggestions** — upsert per (record, question) with `value`, `score`, `agent`, + `type`; list per record. +- **Projections** — `get(workspace_id, reference)` → cells of response ∨ suggestion + per question with `source` provenance. **Responses (read-only this slice)** — + `GET` returns literal `null` with 200 when absent (mapped to `None`, not an + error); values are double-wrapped `{name: {"value": …}}` and unwrapped here. + +Domain classes are thin Pydantic models with behavior (e.g. `SchemaVersion.find_column`, +`SearchPage.records`), constructed from DTOs by the resources — same layering the +frontend proved testable without a live backend. + +## 5. Agentic CLI + +v2 commands register at the **top level** of the existing `extralit` app — no `v2` +prefix in the interface — built over the sync facade: + +``` +extralit schemas list | get | create | publish | versions +extralit records upsert | search | list | delete +extralit questions list +extralit suggestions upsert +extralit projection get +extralit references get +``` + +The v2 model owns the CLI surface going forward. Consequence: the existing v1 +`extralit schemas` subcommand (workspace-file Pandera management) is **replaced** by +the v2 implementation — a deliberate breaking change; the CLI leads the v1→v2 +transition even while the SDK library keeps the v1 surface intact. All other v1 +subcommands (`datasets`, `users`, `workspaces`, `extraction`, …) remain untouched +until Phase 6. + +- **JSON-first.** Every command accepts `--json`; when stdout is not a TTY, JSON is + the default automatically. JSON shapes are the serialized DTOs — stable because + they are drift-gated by the contract layer. Rich tables remain the TTY default + for humans. +- **Exit-code and stream contract.** stdout carries data only; errors are structured + JSON on stderr (`{"error": {"type", "status", "detail"}}`). Exit codes: + 0 success, 1 API/runtime error, 2 usage error, 3 validation (422). +- **Non-interactive by construction.** Env vars (`EXTRALIT_API_URL`, + `EXTRALIT_API_KEY` or username/password) and the credentials file; no prompt is + ever emitted when stdin is not a TTY. +- **Piping.** `records upsert` reads JSONL from stdin or `--file`; `records search + --limit/--offset` page through cleanly for chaining. +- **Startup budget.** The v2 commands register into `cli/app.py` via lazy callbacks; + heavy deps (pandas, pandera, datasets) import only inside the commands that use + them. Budget: `extralit schemas --help` completes in < 300 ms on the Orin dev + host, verified with `python -X importtime` in a CI check. + +## 6. Error handling + +`V2APIError(status, detail)` base → `AuthError` (post-refresh 401), +`NotFoundError`, `ValidationError` (normalizes both server 422 body shapes — +`detail: str` and `detail: [{loc, msg}]`). The CLI maps this hierarchy onto the +exit-code contract. Search totals are documented as approximate rather than +pretending exactness. + +## 7. Testing + +- **Unit (no server):** pytest + pytest-asyncio + pytest-httpx. Resource tests pin + each wire quirk by name: double-wrap/unwrap, name↔id suggestion join, + both 422 shapes, null-response-200, bulk chunking + bounded concurrency + + mid-batch failure behavior, token refresh on 401. Sync-facade smoke tests run + inside a live event loop (Jupyter simulation). CLI tests via Typer's runner + assert `--json` output shapes, stderr error JSON, and exit codes. +- **CI gates:** codegen no-drift, snapshot-vs-server, import-boundary grep + (`extralit/v2` must not import v1 modules), CLI startup-time budget. +- **Integration (opt-in marker, live stack):** one end-to-end extraction-loop test + (publish → upsert → suggest → search → projection) reusing the deterministic + seeding approach from the frontend's `e2e/v2/`. + +## Scope + +**In this slice:** everything above — contract layer, transport + sync facade, +the five resources, the top-level CLI verbs with JSON-first output (including the +replacement of the v1 `schemas` subcommand), unit tests + CI gates. + +**Deferred by design:** responses *write* (submit/draft/discard — the review loop +belongs to a follow-up once the frontend's Phase 5 queue lands), DataFrame/parquet/HF +export, `rebuild-index` and other admin verbs, webhooks, span questions, and the +retirement of the remaining v1 CLI subcommands and SDK surface (Phase 6) this +boundary exists to enable. diff --git a/extralit-frontend/.gitignore b/extralit-frontend/.gitignore index c3277549b..e4086ccca 100644 --- a/extralit-frontend/.gitignore +++ b/extralit-frontend/.gitignore @@ -10,3 +10,6 @@ node_modules/ dist/ .vercel .env* + +# v2 e2e generated seed output (regenerated per run) +e2e/v2/seed/seed-output.json diff --git a/extralit-frontend/__mocks__/tabulator-tables.js b/extralit-frontend/__mocks__/tabulator-tables.js index 28ab26a5d..941440627 100644 --- a/extralit-frontend/__mocks__/tabulator-tables.js +++ b/extralit-frontend/__mocks__/tabulator-tables.js @@ -1,11 +1,26 @@ // Mock implementation for tabulator-tables export class TabulatorFull { - constructor() { + // Test hooks: how many times the ctor ran (rebuild detection) and the latest instance + // (so tests can fire its stored event handlers). Reset these in a test's beforeEach. + static constructed = 0; + static latest = null; + + constructor(element, options) { + this.element = element; + this.options = options || {}; + this.handlers = {}; this.rows = []; this.columns = []; this.data = []; // Mock all required methods to prevent errors this.initialized = true; + TabulatorFull.constructed += 1; + TabulatorFull.latest = this; + } + + // Fire a stored handler from a test, e.g. instance.emit("cellEdited", fakeCell). + emit(event, ...args) { + this.handlers[event]?.(...args); } addRow(data, position, index) { @@ -70,10 +85,15 @@ export class TabulatorFull { return true; } - on() { + on(event, callback) { + this.handlers[event] = callback; return this; } + destroy() { + return true; + } + redraw() { return true; } diff --git a/extralit-frontend/components/features/annotation/container/questions/form/shared-components/label-selection/LabelSelection.component.vue b/extralit-frontend/components/base/inputs/label-selection/LabelSelection.component.vue similarity index 98% rename from extralit-frontend/components/features/annotation/container/questions/form/shared-components/label-selection/LabelSelection.component.vue rename to extralit-frontend/components/base/inputs/label-selection/LabelSelection.component.vue index 2863ba95c..33e78ca76 100644 --- a/extralit-frontend/components/features/annotation/container/questions/form/shared-components/label-selection/LabelSelection.component.vue +++ b/extralit-frontend/components/base/inputs/label-selection/LabelSelection.component.vue @@ -1,21 +1,21 @@ @@ -93,12 +93,6 @@ import { useLabelSelectionViewModel } from "./useLabelSelectionViewModel"; export default { name: "LabelSelectionComponent", - // Consumed via `v-model="question.answer.values"` from parent (Single/Multi/Rating/Span). - // Vue 3 removed the `model: { prop, event }` option, so `v-model` now desugars to - // `:modelValue` + `@update:modelValue`. The selection list is mutated in place - // (`option.isSelected = ...`), so reactivity flows via the shared array reference; - // we still declare `update:modelValue` for correctness. - emits: ["update:modelValue", "on-focus", "on-selected"], props: { maxOptionsToShowBeforeCollapse: { type: Number, @@ -131,6 +125,15 @@ export default { default: true, }, }, + // Consumed via `v-model="question.answer.values"` from parent (Single/Multi/Rating/Span). + // Vue 3 removed the `model: { prop, event }` option, so `v-model` now desugars to + // `:modelValue` + `@update:modelValue`. The selection list is mutated in place + // (`option.isSelected = ...`), so reactivity flows via the shared array reference; + // we still declare `update:modelValue` for correctness. + emits: ["update:modelValue", "on-focus", "on-selected"], + setup(props) { + return useLabelSelectionViewModel(props); + }, data() { return { searchInput: "", @@ -138,32 +141,6 @@ export default { keyCode: "", }; }, - created() { - this.searchRef = `${this.componentId}SearchFilterRef`; - }, - watch: { - isFocused: { - immediate: true, - handler(newValue) { - if (newValue) { - this.$nextTick(() => { - const options = this.$refs?.options; - if (options?.some((o) => o.contains(document.activeElement))) { - return; - } - - if (options?.length > 0) { - options[0].focus({ - preventScroll: true, - }); - } else { - this.$refs.searchComponentRef?.searchInputRef.focus(); - } - }); - } - }, - }, - }, computed: { keyboards() { return this.modelValue.reduce((acc, option, index) => { @@ -227,6 +204,32 @@ export default { return this.maxOptionsToShowBeforeCollapse ?? this.modelValue.length + 1; }, }, + watch: { + isFocused: { + immediate: true, + handler(newValue) { + if (newValue) { + this.$nextTick(() => { + const options = this.$refs?.options; + if (options?.some((o) => o.contains(document.activeElement))) { + return; + } + + if (options?.length > 0) { + options[0].focus({ + preventScroll: true, + }); + } else { + this.$refs.searchComponentRef?.searchInputRef.focus(); + } + }); + } + }, + }, + }, + created() { + this.searchRef = `${this.componentId}SearchFilterRef`; + }, methods: { keyboardHandler($event) { if (this.timer) clearTimeout(this.timer); @@ -341,9 +344,6 @@ export default { return tooltip; }, }, - setup(props) { - return useLabelSelectionViewModel(props); - }, }; diff --git a/extralit-frontend/components/features/annotation/container/questions/form/shared-components/label-selection/labelSelection.component.spec.js b/extralit-frontend/components/base/inputs/label-selection/labelSelection.component.spec.js similarity index 100% rename from extralit-frontend/components/features/annotation/container/questions/form/shared-components/label-selection/labelSelection.component.spec.js rename to extralit-frontend/components/base/inputs/label-selection/labelSelection.component.spec.js diff --git a/extralit-frontend/components/features/annotation/container/questions/form/shared-components/label-selection/useLabelSelectionViewModel.ts b/extralit-frontend/components/base/inputs/label-selection/useLabelSelectionViewModel.ts similarity index 100% rename from extralit-frontend/components/features/annotation/container/questions/form/shared-components/label-selection/useLabelSelectionViewModel.ts rename to extralit-frontend/components/base/inputs/label-selection/useLabelSelectionViewModel.ts diff --git a/extralit-frontend/components/features/annotation/container/questions/form/ranking/drag-and-drop-selection/DndSelection.component.vue b/extralit-frontend/components/base/inputs/ranking/DndSelection.component.vue similarity index 95% rename from extralit-frontend/components/features/annotation/container/questions/form/ranking/drag-and-drop-selection/DndSelection.component.vue rename to extralit-frontend/components/base/inputs/ranking/DndSelection.component.vue index 906ce32b3..2c36d96ad 100644 --- a/extralit-frontend/components/features/annotation/container/questions/form/ranking/drag-and-drop-selection/DndSelection.component.vue +++ b/extralit-frontend/components/base/inputs/ranking/DndSelection.component.vue @@ -12,9 +12,9 @@