From a9d031299a0a4790c91766962e49d72a2601b3b7 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Fri, 10 Jul 2026 06:21:34 +0000 Subject: [PATCH 01/29] feat(server): add openapi-dump CLI command for v2 schema export --- .../src/extralit_server/cli/__init__.py | 2 ++ .../src/extralit_server/cli/openapi_dump.py | 26 ++++++++++++++ .../tests/unit/test_openapi_dump.py | 36 +++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 extralit-server/src/extralit_server/cli/openapi_dump.py create mode 100644 extralit-server/tests/unit/test_openapi_dump.py diff --git a/extralit-server/src/extralit_server/cli/__init__.py b/extralit-server/src/extralit_server/cli/__init__.py index 54ecaa73a..63ce5f7af 100644 --- a/extralit-server/src/extralit_server/cli/__init__.py +++ b/extralit-server/src/extralit_server/cli/__init__.py @@ -2,6 +2,7 @@ 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 @@ -13,6 +14,7 @@ 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() diff --git a/extralit-server/src/extralit_server/cli/openapi_dump.py b/extralit-server/src/extralit_server/cli/openapi_dump.py new file mode 100644 index 000000000..8fc9ac9bc --- /dev/null +++ b/extralit-server/src/extralit_server/cli/openapi_dump.py @@ -0,0 +1,26 @@ +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) diff --git a/extralit-server/tests/unit/test_openapi_dump.py b/extralit-server/tests/unit/test_openapi_dump.py new file mode 100644 index 000000000..92c5c486e --- /dev/null +++ b/extralit-server/tests/unit/test_openapi_dump.py @@ -0,0 +1,36 @@ +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" From 78c01892918cb389f1e1b3874b8e478b699795e3 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Fri, 10 Jul 2026 06:29:31 +0000 Subject: [PATCH 02/29] feat(frontend): v2 OpenAPI snapshot + openapi-typescript typegen with CI drift gates --- .github/workflows/extralit-frontend.yml | 5 + .github/workflows/extralit-server.yml | 7 + extralit-frontend/eslint.config.mjs | 1 + extralit-frontend/package-lock.json | 237 + extralit-frontend/package.json | 8 +- .../v2/infrastructure/api/generated/v2-api.ts | 4471 +++++++++++++++ .../v2/infrastructure/api/openapi.json | 4816 +++++++++++++++++ 7 files changed, 9543 insertions(+), 2 deletions(-) create mode 100644 extralit-frontend/v2/infrastructure/api/generated/v2-api.ts create mode 100644 extralit-frontend/v2/infrastructure/api/openapi.json 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/extralit-frontend/eslint.config.mjs b/extralit-frontend/eslint.config.mjs index cebbb6c9d..129f37390 100644 --- a/extralit-frontend/eslint.config.mjs +++ b/extralit-frontend/eslint.config.mjs @@ -28,6 +28,7 @@ export default [ "v1/domain/entities/document/Document.ts", "v1/domain/usecases/get-extraction-completion-use-case.ts", "components/base/base-render-table/**", + "v2/infrastructure/api/generated/**", ], }, diff --git a/extralit-frontend/package-lock.json b/extralit-frontend/package-lock.json index e8f1bfead..d01aacfb7 100644 --- a/extralit-frontend/package-lock.json +++ b/extralit-frontend/package-lock.json @@ -70,6 +70,7 @@ "eslint-plugin-vue": "^10.9.2", "globals": "^17.7.0", "happy-dom": "^20.10.6", + "openapi-typescript": "^7.13.0", "prettier": "^3.8.4", "rimraf": "^6.1.3", "typescript": "^5.4.5", @@ -5518,6 +5519,106 @@ "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", "license": "MIT" }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/ajv/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.17", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.17.tgz", + "integrity": "sha512-wsV2keCt6B806XpSdezbWZ9aFJYf14YVh+XQf0ESt7M90yqVuxH9//PxvtC70sgj9OCkRM3nRaLfu4MsGQZRig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.2.0", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@remirror/core-constants": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", @@ -8073,6 +8174,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -8968,6 +9079,13 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, "node_modules/char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", @@ -9206,6 +9324,13 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -11713,6 +11838,19 @@ "node": ">=0.8.19" } }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -15660,6 +15798,71 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript/node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openapi-typescript/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/openapi-typescript/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -16239,6 +16442,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -17298,6 +17511,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -19510,6 +19733,13 @@ "punycode": "^2.1.0" } }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -20710,6 +20940,13 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/yaml-eslint-parser": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/yaml-eslint-parser/-/yaml-eslint-parser-2.0.0.tgz", diff --git a/extralit-frontend/package.json b/extralit-frontend/package.json index a19375b80..97f1e5d40 100644 --- a/extralit-frontend/package.json +++ b/extralit-frontend/package.json @@ -18,7 +18,10 @@ "e2e:silent": "npx playwright test", "test": "vitest run", "test:watch": "vitest", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "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" }, "dependencies": { "@codescouts/events": "^1.0.2", @@ -63,12 +66,12 @@ }, "devDependencies": { "@codescouts/test": "^1.0.7", + "@eslint/js": "^10.0.1", "@intlify/eslint-plugin-vue-i18n": "^4.5.1", "@nuxt/test-utils": "^4.0.3", "@playwright/test": "^1.61.1", "@types/papaparse": "^5.3.16", "@types/tabulator-tables": "^6.2.0", - "@eslint/js": "^10.0.1", "@typescript-eslint/eslint-plugin": "^8.62.0", "@typescript-eslint/parser": "^8.62.0", "@typescript-eslint/typescript-estree": "^8.62.0", @@ -82,6 +85,7 @@ "eslint-plugin-vue": "^10.9.2", "globals": "^17.7.0", "happy-dom": "^20.10.6", + "openapi-typescript": "^7.13.0", "prettier": "^3.8.4", "rimraf": "^6.1.3", "typescript": "^5.4.5", diff --git a/extralit-frontend/v2/infrastructure/api/generated/v2-api.ts b/extralit-frontend/v2/infrastructure/api/generated/v2-api.ts new file mode 100644 index 000000000..99da1305a --- /dev/null +++ b/extralit-frontend/v2/infrastructure/api/generated/v2-api.ts @@ -0,0 +1,4471 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/projection/references/{reference}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Reference Projection */ + get: operations["get_reference_projection_projection_references__reference__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/questions/{question_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Question */ + get: operations["get_question_questions__question_id__get"]; + /** Update Question */ + put: operations["update_question_questions__question_id__put"]; + post?: never; + /** Delete Question */ + delete: operations["delete_question_questions__question_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/records/{record_id}/responses": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Own Response */ + get: operations["get_own_response_records__record_id__responses_get"]; + /** Upsert Response */ + put: operations["upsert_response_records__record_id__responses_put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/records/{record_id}/suggestions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Suggestions */ + get: operations["list_suggestions_records__record_id__suggestions_get"]; + /** Upsert Suggestion */ + put: operations["upsert_suggestion_records__record_id__suggestions_put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/references/{reference}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Reference View + * @description The document's project-level extraction view: all v2 records across every schema in + * the workspace that share this `reference` (spec §6), grouped per schema. + * + * An unknown reference returns an empty view (200): the reference is a free-form join + * key, not an entity, so "no extractions yet" is not an error. + */ + get: operations["get_reference_view_references__reference__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/schemas": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Schemas */ + get: operations["list_schemas_schemas_get"]; + put?: never; + /** Create Schema */ + post: operations["create_schema_schemas_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/schemas/{schema_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Schema */ + get: operations["get_schema_schemas__schema_id__get"]; + /** Update Schema */ + put: operations["update_schema_schemas__schema_id__put"]; + post?: never; + /** Delete Schema */ + delete: operations["delete_schema_schemas__schema_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/schemas/{schema_id}/columns": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Schema Columns */ + get: operations["get_schema_columns_schemas__schema_id__columns_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/schemas/{schema_id}/questions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Questions */ + get: operations["list_questions_schemas__schema_id__questions_get"]; + put?: never; + /** Create Question */ + post: operations["create_question_schemas__schema_id__questions_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/schemas/{schema_id}/records": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Schema Records */ + get: operations["list_schema_records_schemas__schema_id__records_get"]; + put?: never; + post?: never; + /** Delete Schema Records */ + delete: operations["delete_schema_records_schemas__schema_id__records_delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/schemas/{schema_id}/records:bulk-upsert": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Bulk Upsert Schema Records */ + post: operations["bulk_upsert_schema_records_schemas__schema_id__records_bulk_upsert_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/schemas/{schema_id}/records:search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Search Schema Records + * @description Full-text (BM25) + scalar-filter search over a schema's records. + * + * Lance supplies matching record ids and scores; payloads are hydrated from Postgres + * (the source of truth) and returned in the engine's hit order. `total` is the engine's + * total match count, which may exceed the returned page. + */ + post: operations["search_schema_records_schemas__schema_id__records_search_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/schemas/{schema_id}/versions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Schema Versions */ + get: operations["list_schema_versions_schemas__schema_id__versions_get"]; + put?: never; + /** Publish Schema Version */ + post: operations["publish_schema_version_schemas__schema_id__versions_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/schemas/{schema_id}/versions/{version}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Schema Version */ + get: operations["get_schema_version_schemas__schema_id__versions__version__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/schemas/{schema_id}:rebuild-index": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Rebuild Schema Index + * @description Drop and repopulate the schema's Lance table from Postgres (the recovery path). + * + * Unlike the write-time sync hooks, this surfaces engine errors to the caller — the + * operator explicitly asked to rebuild. For large schemas the rebuild may take tens of + * seconds; consider running as a background job (via the CLI) if timeouts are a concern. + */ + post: operations["rebuild_schema_index_schemas__schema_id__rebuild_index_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/token": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create Token */ + post: operations["create_token_token_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/token/refresh": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Refresh Token + * @description Refresh an access token using a valid refresh token. + * This endpoint does not require database access, improving reliability. + */ + post: operations["refresh_token_token_refresh_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** Body_create_token_token_post */ + Body_create_token_token_post: { + /** Password */ + password: string; + /** Username */ + username: string; + }; + /** ProjectionCell */ + ProjectionCell: { + /** Question Name */ + question_name: string; + /** Source */ + source?: ("response" | "suggestion") | null; + /** Value */ + value?: unknown | null; + }; + /** ProjectionRecord */ + ProjectionRecord: { + /** Cells */ + cells: components["schemas"]["ProjectionCell"][]; + /** + * Record Id + * Format: uuid + */ + record_id: string; + /** Reference */ + reference: string; + /** + * Schema Id + * Format: uuid + */ + schema_id: string; + }; + /** ProjectionView */ + ProjectionView: { + /** Records */ + records: components["schemas"]["ProjectionRecord"][]; + /** Reference */ + reference: string; + /** Total Records */ + total_records: number; + }; + /** QuestionCreate */ + QuestionCreate: { + /** Columns */ + columns: string[]; + /** Description */ + description?: string | null; + /** Name */ + name: string; + /** + * Required + * @default false + */ + required: boolean; + /** Settings */ + settings?: Record; + /** Title */ + title: string; + type: components["schemas"]["QuestionType"]; + }; + /** QuestionRead */ + QuestionRead: { + /** Columns */ + columns: string[]; + /** Description */ + description: string | null; + /** + * Id + * Format: uuid + */ + id: string; + /** + * Inserted At + * Format: date-time + */ + inserted_at: string; + /** Name */ + name: string; + /** Required */ + required: boolean; + /** + * Schema Id + * Format: uuid + */ + schema_id: string; + /** Settings */ + settings: Record; + /** Title */ + title: string; + type: components["schemas"]["QuestionType"]; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + }; + /** + * QuestionType + * @enum {string} + */ + QuestionType: "text" | "rating" | "label_selection" | "multi_label_selection" | "ranking" | "span" | "table"; + /** QuestionUpdate */ + QuestionUpdate: { + /** Columns */ + columns?: string[] | null; + /** Description */ + description?: string | null; + /** Required */ + required?: boolean | null; + /** Settings */ + settings?: Record | null; + /** Title */ + title?: string | null; + }; + /** Questions */ + Questions: { + /** Items */ + items: components["schemas"]["QuestionRead"][]; + }; + /** RecordFilter */ + RecordFilter: { + /** Column */ + column: string; + /** + * Op + * @enum {string} + */ + op: "eq" | "in" | "ge" | "le"; + /** Value */ + value: unknown; + }; + /** RecordRead */ + RecordRead: { + /** External Id */ + external_id: string | null; + /** Fields */ + fields: Record; + /** + * Id + * Format: uuid + */ + id: string; + /** + * Inserted At + * Format: date-time + */ + inserted_at: string; + /** Metadata */ + metadata?: Record | null; + /** Reference */ + reference: string; + /** + * Schema Id + * Format: uuid + */ + schema_id: string; + /** + * Schema Version Id + * Format: uuid + */ + schema_version_id: string; + status: components["schemas"]["V2RecordStatus"]; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + }; + /** RecordSearchQuery */ + RecordSearchQuery: { + /** Filters */ + filters?: components["schemas"]["RecordFilter"][]; + /** + * Limit + * @default 50 + */ + limit: number; + /** + * Offset + * @default 0 + */ + offset: number; + /** Text */ + text?: string | null; + }; + /** + * RecordUpsert + * @description One bulk-upsert item. + * + * `fields` and `reference` are always written. `metadata` and `status` are patch-like: + * when omitted (None) on an update they preserve the existing row's values (they cannot + * be cleared via upsert); on insert they default to no metadata / `pending`. + */ + RecordUpsert: { + /** External Id */ + external_id?: string | null; + /** Fields */ + fields: Record; + /** Metadata */ + metadata?: Record | null; + /** Reference */ + reference: string; + /** + * Schema Version Id + * @description Pin to a specific version; defaults to the schema's current_version_id + */ + schema_version_id?: string | null; + status?: components["schemas"]["V2RecordStatus"] | null; + }; + /** Records */ + Records: { + /** Items */ + items: components["schemas"]["RecordRead"][]; + /** Total */ + total: number; + }; + /** RecordsBulkUpsert */ + RecordsBulkUpsert: { + /** Items */ + items: components["schemas"]["RecordUpsert"][]; + }; + /** ReferenceGroup */ + ReferenceGroup: { + /** Records */ + records: components["schemas"]["RecordRead"][]; + /** + * Schema Id + * Format: uuid + */ + schema_id: string; + /** Schema Name */ + schema_name: string; + }; + /** ReferenceView */ + ReferenceView: { + /** Groups */ + groups: components["schemas"]["ReferenceGroup"][]; + /** Reference */ + reference: string; + /** Total Records */ + total_records: number; + }; + /** + * RefreshTokenRequest + * @description Refresh token request model + */ + RefreshTokenRequest: { + /** Refresh Token */ + refresh_token: string; + }; + /** ResponseRead */ + ResponseRead: { + /** + * Id + * Format: uuid + */ + id: string; + /** + * Inserted At + * Format: date-time + */ + inserted_at: string; + /** + * Record Id + * Format: uuid + */ + record_id: string; + status: components["schemas"]["ResponseStatus"]; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + /** + * User Id + * Format: uuid + */ + user_id: string; + /** Values */ + values: Record | null; + }; + /** + * ResponseStatus + * @enum {string} + */ + ResponseStatus: "draft" | "submitted" | "discarded"; + /** ResponseUpsert */ + ResponseUpsert: { + status: components["schemas"]["ResponseStatus"]; + /** Values */ + values?: { + [key: string]: Record; + } | null; + }; + /** SchemaCreate */ + SchemaCreate: { + /** Name */ + name: string; + /** Settings */ + settings?: Record; + /** + * Workspace Id + * Format: uuid + */ + workspace_id: string; + }; + /** SchemaRead */ + SchemaRead: { + /** Current Version Id */ + current_version_id: string | null; + /** + * Id + * Format: uuid + */ + id: string; + /** + * Inserted At + * Format: date-time + */ + inserted_at: string; + /** Name */ + name: string; + /** Settings */ + settings: Record; + status: components["schemas"]["SchemaStatus"]; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + /** + * Workspace Id + * Format: uuid + */ + workspace_id: string; + }; + /** + * SchemaStatus + * @enum {string} + */ + SchemaStatus: "draft" | "published"; + /** SchemaUpdate */ + SchemaUpdate: { + /** Name */ + name?: string | null; + /** Settings */ + settings?: Record | null; + }; + /** SchemaVersionCreate */ + SchemaVersionCreate: { + /** + * Body + * @description Pandera DataFrameSchema serialized via .to_json() + */ + body: string; + /** Review Widgets */ + review_widgets?: { + [key: string]: Record; + }; + }; + /** SchemaVersionRead */ + SchemaVersionRead: { + /** Checksum */ + checksum: string; + /** Columns Cache */ + columns_cache: Record[]; + /** Etag */ + etag: string; + /** + * Id + * Format: uuid + */ + id: string; + /** + * Inserted At + * Format: date-time + */ + inserted_at: string; + /** Object Key */ + object_key: string; + /** Object Version Id */ + object_version_id: string | null; + /** Parent Version Id */ + parent_version_id: string | null; + /** Review Widgets */ + review_widgets: { + [key: string]: Record; + }; + /** + * Schema Id + * Format: uuid + */ + schema_id: string; + /** Version */ + version: number; + }; + /** Schemas */ + Schemas: { + /** Items */ + items: components["schemas"]["SchemaRead"][]; + }; + /** SuggestionRead */ + SuggestionRead: { + /** Agent */ + agent: string | null; + /** + * Id + * Format: uuid + */ + id: string; + /** + * Inserted At + * Format: date-time + */ + inserted_at: string; + /** + * Question Id + * Format: uuid + */ + question_id: string; + /** + * Record Id + * Format: uuid + */ + record_id: string; + /** Score */ + score: number | number[] | null; + type: components["schemas"]["SuggestionType"] | null; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + /** Value */ + value: unknown; + }; + /** + * SuggestionType + * @enum {string} + */ + SuggestionType: "model" | "human" | "selection"; + /** SuggestionUpsert */ + SuggestionUpsert: { + /** Agent */ + agent?: string | null; + /** + * Question Id + * Format: uuid + */ + question_id: string; + /** Score */ + score?: number | number[] | null; + type?: components["schemas"]["SuggestionType"] | null; + /** Value */ + value: unknown; + }; + /** Suggestions */ + Suggestions: { + /** Items */ + items: components["schemas"]["SuggestionRead"][]; + }; + /** + * Token + * @description Token response model + */ + Token: { + /** Access Token */ + access_token: string; + /** Refresh Token */ + refresh_token?: string | null; + /** + * Token Type + * @default bearer + */ + token_type: string; + }; + /** + * V2RecordStatus + * @description v2 record status. Distinct from v1 RecordStatus: adds `discarded` and maps to its + * own PG enum type (v2_record_status_enum) so v1's record_status_enum is untouched. + * @enum {string} + */ + V2RecordStatus: "pending" | "completed" | "discarded"; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + get_reference_projection_projection_references__reference__get: { + parameters: { + query: { + /** @description Workspace to scope the view (required) */ + workspace_id: string; + }; + header?: never; + path: { + reference: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectionView"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::MissingDatasetRecordsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ForbiddenOperationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityNotFoundError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityAlreadyExistsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ValidationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "builtins.TypeError" + * } + * } + */ + "application/json": unknown; + }; + }; + }; + }; + get_question_questions__question_id__get: { + parameters: { + query?: never; + header?: never; + path: { + question_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["QuestionRead"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::MissingDatasetRecordsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ForbiddenOperationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityNotFoundError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityAlreadyExistsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ValidationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "builtins.TypeError" + * } + * } + */ + "application/json": unknown; + }; + }; + }; + }; + update_question_questions__question_id__put: { + parameters: { + query?: never; + header?: never; + path: { + question_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["QuestionUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["QuestionRead"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::MissingDatasetRecordsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ForbiddenOperationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityNotFoundError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityAlreadyExistsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ValidationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "builtins.TypeError" + * } + * } + */ + "application/json": unknown; + }; + }; + }; + }; + delete_question_questions__question_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + question_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::MissingDatasetRecordsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ForbiddenOperationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityNotFoundError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityAlreadyExistsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ValidationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "builtins.TypeError" + * } + * } + */ + "application/json": unknown; + }; + }; + }; + }; + get_own_response_records__record_id__responses_get: { + parameters: { + query?: never; + header?: never; + path: { + record_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ResponseRead"] | null; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::MissingDatasetRecordsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ForbiddenOperationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityNotFoundError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityAlreadyExistsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ValidationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "builtins.TypeError" + * } + * } + */ + "application/json": unknown; + }; + }; + }; + }; + upsert_response_records__record_id__responses_put: { + parameters: { + query?: never; + header?: never; + path: { + record_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ResponseUpsert"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ResponseRead"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::MissingDatasetRecordsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ForbiddenOperationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityNotFoundError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityAlreadyExistsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ValidationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "builtins.TypeError" + * } + * } + */ + "application/json": unknown; + }; + }; + }; + }; + list_suggestions_records__record_id__suggestions_get: { + parameters: { + query?: never; + header?: never; + path: { + record_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Suggestions"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::MissingDatasetRecordsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ForbiddenOperationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityNotFoundError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityAlreadyExistsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ValidationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "builtins.TypeError" + * } + * } + */ + "application/json": unknown; + }; + }; + }; + }; + upsert_suggestion_records__record_id__suggestions_put: { + parameters: { + query?: never; + header?: never; + path: { + record_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SuggestionUpsert"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuggestionRead"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::MissingDatasetRecordsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ForbiddenOperationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityNotFoundError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityAlreadyExistsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ValidationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "builtins.TypeError" + * } + * } + */ + "application/json": unknown; + }; + }; + }; + }; + get_reference_view_references__reference__get: { + parameters: { + query: { + /** @description Workspace to scope the cross-schema view (required) */ + workspace_id: string; + }; + header?: never; + path: { + reference: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReferenceView"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::MissingDatasetRecordsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ForbiddenOperationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityNotFoundError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityAlreadyExistsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ValidationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "builtins.TypeError" + * } + * } + */ + "application/json": unknown; + }; + }; + }; + }; + list_schemas_schemas_get: { + parameters: { + query: { + /** @description Workspace to list schemas for (required) */ + workspace_id: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Schemas"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::MissingDatasetRecordsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ForbiddenOperationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityNotFoundError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityAlreadyExistsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ValidationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "builtins.TypeError" + * } + * } + */ + "application/json": unknown; + }; + }; + }; + }; + create_schema_schemas_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SchemaCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SchemaRead"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::MissingDatasetRecordsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ForbiddenOperationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityNotFoundError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityAlreadyExistsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ValidationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "builtins.TypeError" + * } + * } + */ + "application/json": unknown; + }; + }; + }; + }; + get_schema_schemas__schema_id__get: { + parameters: { + query?: never; + header?: never; + path: { + schema_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SchemaRead"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::MissingDatasetRecordsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ForbiddenOperationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityNotFoundError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityAlreadyExistsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ValidationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "builtins.TypeError" + * } + * } + */ + "application/json": unknown; + }; + }; + }; + }; + update_schema_schemas__schema_id__put: { + parameters: { + query?: never; + header?: never; + path: { + schema_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SchemaUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SchemaRead"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::MissingDatasetRecordsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ForbiddenOperationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityNotFoundError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityAlreadyExistsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ValidationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "builtins.TypeError" + * } + * } + */ + "application/json": unknown; + }; + }; + }; + }; + delete_schema_schemas__schema_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + schema_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SchemaRead"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::MissingDatasetRecordsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ForbiddenOperationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityNotFoundError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityAlreadyExistsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ValidationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "builtins.TypeError" + * } + * } + */ + "application/json": unknown; + }; + }; + }; + }; + get_schema_columns_schemas__schema_id__columns_get: { + parameters: { + query?: never; + header?: never; + path: { + schema_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record[]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::MissingDatasetRecordsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ForbiddenOperationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityNotFoundError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityAlreadyExistsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ValidationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "builtins.TypeError" + * } + * } + */ + "application/json": unknown; + }; + }; + }; + }; + list_questions_schemas__schema_id__questions_get: { + parameters: { + query?: never; + header?: never; + path: { + schema_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Questions"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::MissingDatasetRecordsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ForbiddenOperationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityNotFoundError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityAlreadyExistsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ValidationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "builtins.TypeError" + * } + * } + */ + "application/json": unknown; + }; + }; + }; + }; + create_question_schemas__schema_id__questions_post: { + parameters: { + query?: never; + header?: never; + path: { + schema_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["QuestionCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["QuestionRead"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::MissingDatasetRecordsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ForbiddenOperationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityNotFoundError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityAlreadyExistsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ValidationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "builtins.TypeError" + * } + * } + */ + "application/json": unknown; + }; + }; + }; + }; + list_schema_records_schemas__schema_id__records_get: { + parameters: { + query?: { + offset?: number; + limit?: number; + status?: components["schemas"]["V2RecordStatus"] | null; + reference?: string | null; + }; + header?: never; + path: { + schema_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Records"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::MissingDatasetRecordsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ForbiddenOperationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityNotFoundError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityAlreadyExistsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ValidationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "builtins.TypeError" + * } + * } + */ + "application/json": unknown; + }; + }; + }; + }; + delete_schema_records_schemas__schema_id__records_delete: { + parameters: { + query: { + /** @description Comma-separated record ids to delete */ + ids: string; + }; + header?: never; + path: { + schema_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::MissingDatasetRecordsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ForbiddenOperationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityNotFoundError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityAlreadyExistsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ValidationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "builtins.TypeError" + * } + * } + */ + "application/json": unknown; + }; + }; + }; + }; + bulk_upsert_schema_records_schemas__schema_id__records_bulk_upsert_post: { + parameters: { + query?: never; + header?: never; + path: { + schema_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RecordsBulkUpsert"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Records"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::MissingDatasetRecordsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ForbiddenOperationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityNotFoundError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityAlreadyExistsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ValidationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "builtins.TypeError" + * } + * } + */ + "application/json": unknown; + }; + }; + }; + }; + search_schema_records_schemas__schema_id__records_search_post: { + parameters: { + query?: never; + header?: never; + path: { + schema_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RecordSearchQuery"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Records"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::MissingDatasetRecordsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ForbiddenOperationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityNotFoundError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityAlreadyExistsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ValidationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "builtins.TypeError" + * } + * } + */ + "application/json": unknown; + }; + }; + }; + }; + list_schema_versions_schemas__schema_id__versions_get: { + parameters: { + query?: never; + header?: never; + path: { + schema_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SchemaVersionRead"][]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::MissingDatasetRecordsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ForbiddenOperationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityNotFoundError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityAlreadyExistsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ValidationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "builtins.TypeError" + * } + * } + */ + "application/json": unknown; + }; + }; + }; + }; + publish_schema_version_schemas__schema_id__versions_post: { + parameters: { + query?: never; + header?: never; + path: { + schema_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SchemaVersionCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SchemaVersionRead"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::MissingDatasetRecordsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ForbiddenOperationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityNotFoundError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityAlreadyExistsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ValidationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "builtins.TypeError" + * } + * } + */ + "application/json": unknown; + }; + }; + }; + }; + get_schema_version_schemas__schema_id__versions__version__get: { + parameters: { + query?: never; + header?: never; + path: { + schema_id: string; + version: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SchemaVersionRead"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::MissingDatasetRecordsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ForbiddenOperationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityNotFoundError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityAlreadyExistsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ValidationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "builtins.TypeError" + * } + * } + */ + "application/json": unknown; + }; + }; + }; + }; + rebuild_schema_index_schemas__schema_id__rebuild_index_post: { + parameters: { + query?: never; + header?: never; + path: { + schema_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: number; + }; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::MissingDatasetRecordsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ForbiddenOperationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityNotFoundError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityAlreadyExistsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ValidationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "builtins.TypeError" + * } + * } + */ + "application/json": unknown; + }; + }; + }; + }; + create_token_token_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/x-www-form-urlencoded": components["schemas"]["Body_create_token_token_post"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Token"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::MissingDatasetRecordsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ForbiddenOperationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityNotFoundError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityAlreadyExistsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ValidationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "builtins.TypeError" + * } + * } + */ + "application/json": unknown; + }; + }; + }; + }; + refresh_token_token_refresh_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RefreshTokenRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Token"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::MissingDatasetRecordsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ForbiddenOperationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityNotFoundError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::EntityAlreadyExistsError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Unprocessable Content */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "extralit.api.errors::ValidationError", + * "params": { + * "extra": "error parameters" + * } + * } + * } + */ + "application/json": unknown; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "detail": { + * "code": "builtins.TypeError" + * } + * } + */ + "application/json": unknown; + }; + }; + }; + }; +} diff --git a/extralit-frontend/v2/infrastructure/api/openapi.json b/extralit-frontend/v2/infrastructure/api/openapi.json new file mode 100644 index 000000000..bb27a0dd0 --- /dev/null +++ b/extralit-frontend/v2/infrastructure/api/openapi.json @@ -0,0 +1,4816 @@ +{ + "components": { + "schemas": { + "Body_create_token_token_post": { + "properties": { + "password": { + "title": "Password", + "type": "string" + }, + "username": { + "title": "Username", + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "title": "Body_create_token_token_post", + "type": "object" + }, + "ProjectionCell": { + "properties": { + "question_name": { + "title": "Question Name", + "type": "string" + }, + "source": { + "anyOf": [ + { + "enum": [ + "response", + "suggestion" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source" + }, + "value": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "title": "Value" + } + }, + "required": [ + "question_name" + ], + "title": "ProjectionCell", + "type": "object" + }, + "ProjectionRecord": { + "properties": { + "cells": { + "items": { + "$ref": "#/components/schemas/ProjectionCell" + }, + "title": "Cells", + "type": "array" + }, + "record_id": { + "format": "uuid", + "title": "Record Id", + "type": "string" + }, + "reference": { + "title": "Reference", + "type": "string" + }, + "schema_id": { + "format": "uuid", + "title": "Schema Id", + "type": "string" + } + }, + "required": [ + "record_id", + "schema_id", + "reference", + "cells" + ], + "title": "ProjectionRecord", + "type": "object" + }, + "ProjectionView": { + "properties": { + "records": { + "items": { + "$ref": "#/components/schemas/ProjectionRecord" + }, + "title": "Records", + "type": "array" + }, + "reference": { + "title": "Reference", + "type": "string" + }, + "total_records": { + "title": "Total Records", + "type": "integer" + } + }, + "required": [ + "reference", + "records", + "total_records" + ], + "title": "ProjectionView", + "type": "object" + }, + "QuestionCreate": { + "properties": { + "columns": { + "items": { + "type": "string" + }, + "minItems": 1, + "title": "Columns", + "type": "array" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "name": { + "maxLength": 200, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "required": { + "default": false, + "title": "Required", + "type": "boolean" + }, + "settings": { + "title": "Settings", + "type": "object" + }, + "title": { + "minLength": 1, + "title": "Title", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/QuestionType" + } + }, + "required": [ + "name", + "title", + "type", + "columns" + ], + "title": "QuestionCreate", + "type": "object" + }, + "QuestionRead": { + "properties": { + "columns": { + "items": { + "type": "string" + }, + "title": "Columns", + "type": "array" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "inserted_at": { + "format": "date-time", + "title": "Inserted At", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "required": { + "title": "Required", + "type": "boolean" + }, + "schema_id": { + "format": "uuid", + "title": "Schema Id", + "type": "string" + }, + "settings": { + "title": "Settings", + "type": "object" + }, + "title": { + "title": "Title", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/QuestionType" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + } + }, + "required": [ + "id", + "schema_id", + "name", + "title", + "description", + "type", + "columns", + "settings", + "required", + "inserted_at", + "updated_at" + ], + "title": "QuestionRead", + "type": "object" + }, + "QuestionType": { + "enum": [ + "text", + "rating", + "label_selection", + "multi_label_selection", + "ranking", + "span", + "table" + ], + "title": "QuestionType", + "type": "string" + }, + "QuestionUpdate": { + "properties": { + "columns": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Columns" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "required": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Required" + }, + "settings": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Settings" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title" + } + }, + "title": "QuestionUpdate", + "type": "object" + }, + "Questions": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/QuestionRead" + }, + "title": "Items", + "type": "array" + } + }, + "required": [ + "items" + ], + "title": "Questions", + "type": "object" + }, + "RecordFilter": { + "properties": { + "column": { + "title": "Column", + "type": "string" + }, + "op": { + "enum": [ + "eq", + "in", + "ge", + "le" + ], + "title": "Op", + "type": "string" + }, + "value": { + "title": "Value" + } + }, + "required": [ + "column", + "op", + "value" + ], + "title": "RecordFilter", + "type": "object" + }, + "RecordRead": { + "properties": { + "external_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "External Id" + }, + "fields": { + "title": "Fields", + "type": "object" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "inserted_at": { + "format": "date-time", + "title": "Inserted At", + "type": "string" + }, + "metadata": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "reference": { + "title": "Reference", + "type": "string" + }, + "schema_id": { + "format": "uuid", + "title": "Schema Id", + "type": "string" + }, + "schema_version_id": { + "format": "uuid", + "title": "Schema Version Id", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/V2RecordStatus" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + } + }, + "required": [ + "id", + "schema_id", + "schema_version_id", + "reference", + "external_id", + "fields", + "status", + "inserted_at", + "updated_at" + ], + "title": "RecordRead", + "type": "object" + }, + "RecordSearchQuery": { + "properties": { + "filters": { + "items": { + "$ref": "#/components/schemas/RecordFilter" + }, + "title": "Filters", + "type": "array" + }, + "limit": { + "default": 50, + "maximum": 1000.0, + "minimum": 1.0, + "title": "Limit", + "type": "integer" + }, + "offset": { + "default": 0, + "minimum": 0.0, + "title": "Offset", + "type": "integer" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Text" + } + }, + "title": "RecordSearchQuery", + "type": "object" + }, + "RecordUpsert": { + "description": "One bulk-upsert item.\n\n`fields` and `reference` are always written. `metadata` and `status` are patch-like:\nwhen omitted (None) on an update they preserve the existing row's values (they cannot\nbe cleared via upsert); on insert they default to no metadata / `pending`.", + "properties": { + "external_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "External Id" + }, + "fields": { + "title": "Fields", + "type": "object" + }, + "metadata": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "reference": { + "maxLength": 500, + "minLength": 1, + "title": "Reference", + "type": "string" + }, + "schema_version_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Pin to a specific version; defaults to the schema's current_version_id", + "title": "Schema Version Id" + }, + "status": { + "anyOf": [ + { + "$ref": "#/components/schemas/V2RecordStatus" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "fields", + "reference" + ], + "title": "RecordUpsert", + "type": "object" + }, + "Records": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/RecordRead" + }, + "title": "Items", + "type": "array" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total" + ], + "title": "Records", + "type": "object" + }, + "RecordsBulkUpsert": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/RecordUpsert" + }, + "maxItems": 500, + "minItems": 1, + "title": "Items", + "type": "array" + } + }, + "required": [ + "items" + ], + "title": "RecordsBulkUpsert", + "type": "object" + }, + "ReferenceGroup": { + "properties": { + "records": { + "items": { + "$ref": "#/components/schemas/RecordRead" + }, + "title": "Records", + "type": "array" + }, + "schema_id": { + "format": "uuid", + "title": "Schema Id", + "type": "string" + }, + "schema_name": { + "title": "Schema Name", + "type": "string" + } + }, + "required": [ + "schema_id", + "schema_name", + "records" + ], + "title": "ReferenceGroup", + "type": "object" + }, + "ReferenceView": { + "properties": { + "groups": { + "items": { + "$ref": "#/components/schemas/ReferenceGroup" + }, + "title": "Groups", + "type": "array" + }, + "reference": { + "title": "Reference", + "type": "string" + }, + "total_records": { + "title": "Total Records", + "type": "integer" + } + }, + "required": [ + "reference", + "groups", + "total_records" + ], + "title": "ReferenceView", + "type": "object" + }, + "RefreshTokenRequest": { + "description": "Refresh token request model", + "properties": { + "refresh_token": { + "title": "Refresh Token", + "type": "string" + } + }, + "required": [ + "refresh_token" + ], + "title": "RefreshTokenRequest", + "type": "object" + }, + "ResponseRead": { + "properties": { + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "inserted_at": { + "format": "date-time", + "title": "Inserted At", + "type": "string" + }, + "record_id": { + "format": "uuid", + "title": "Record Id", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/ResponseStatus" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + }, + "user_id": { + "format": "uuid", + "title": "User Id", + "type": "string" + }, + "values": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Values" + } + }, + "required": [ + "id", + "record_id", + "user_id", + "values", + "status", + "inserted_at", + "updated_at" + ], + "title": "ResponseRead", + "type": "object" + }, + "ResponseStatus": { + "enum": [ + "draft", + "submitted", + "discarded" + ], + "title": "ResponseStatus", + "type": "string" + }, + "ResponseUpsert": { + "properties": { + "status": { + "$ref": "#/components/schemas/ResponseStatus" + }, + "values": { + "anyOf": [ + { + "additionalProperties": { + "type": "object" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Values" + } + }, + "required": [ + "status" + ], + "title": "ResponseUpsert", + "type": "object" + }, + "SchemaCreate": { + "properties": { + "name": { + "maxLength": 200, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "settings": { + "title": "Settings", + "type": "object" + }, + "workspace_id": { + "format": "uuid", + "title": "Workspace Id", + "type": "string" + } + }, + "required": [ + "name", + "workspace_id" + ], + "title": "SchemaCreate", + "type": "object" + }, + "SchemaRead": { + "properties": { + "current_version_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Current Version Id" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "inserted_at": { + "format": "date-time", + "title": "Inserted At", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "settings": { + "title": "Settings", + "type": "object" + }, + "status": { + "$ref": "#/components/schemas/SchemaStatus" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + }, + "workspace_id": { + "format": "uuid", + "title": "Workspace Id", + "type": "string" + } + }, + "required": [ + "id", + "name", + "status", + "current_version_id", + "settings", + "workspace_id", + "inserted_at", + "updated_at" + ], + "title": "SchemaRead", + "type": "object" + }, + "SchemaStatus": { + "enum": [ + "draft", + "published" + ], + "title": "SchemaStatus", + "type": "string" + }, + "SchemaUpdate": { + "properties": { + "name": { + "anyOf": [ + { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "settings": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Settings" + } + }, + "title": "SchemaUpdate", + "type": "object" + }, + "SchemaVersionCreate": { + "properties": { + "body": { + "description": "Pandera DataFrameSchema serialized via .to_json()", + "title": "Body", + "type": "string" + }, + "review_widgets": { + "additionalProperties": { + "type": "object" + }, + "title": "Review Widgets", + "type": "object" + } + }, + "required": [ + "body" + ], + "title": "SchemaVersionCreate", + "type": "object" + }, + "SchemaVersionRead": { + "properties": { + "checksum": { + "title": "Checksum", + "type": "string" + }, + "columns_cache": { + "items": { + "type": "object" + }, + "title": "Columns Cache", + "type": "array" + }, + "etag": { + "title": "Etag", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "inserted_at": { + "format": "date-time", + "title": "Inserted At", + "type": "string" + }, + "object_key": { + "title": "Object Key", + "type": "string" + }, + "object_version_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Object Version Id" + }, + "parent_version_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Parent Version Id" + }, + "review_widgets": { + "additionalProperties": { + "type": "object" + }, + "title": "Review Widgets", + "type": "object" + }, + "schema_id": { + "format": "uuid", + "title": "Schema Id", + "type": "string" + }, + "version": { + "title": "Version", + "type": "integer" + } + }, + "required": [ + "id", + "schema_id", + "version", + "object_key", + "object_version_id", + "etag", + "checksum", + "parent_version_id", + "columns_cache", + "review_widgets", + "inserted_at" + ], + "title": "SchemaVersionRead", + "type": "object" + }, + "Schemas": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/SchemaRead" + }, + "title": "Items", + "type": "array" + } + }, + "required": [ + "items" + ], + "title": "Schemas", + "type": "object" + }, + "SuggestionRead": { + "properties": { + "agent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Agent" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "inserted_at": { + "format": "date-time", + "title": "Inserted At", + "type": "string" + }, + "question_id": { + "format": "uuid", + "title": "Question Id", + "type": "string" + }, + "record_id": { + "format": "uuid", + "title": "Record Id", + "type": "string" + }, + "score": { + "anyOf": [ + { + "type": "number" + }, + { + "items": { + "type": "number" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Score" + }, + "type": { + "anyOf": [ + { + "$ref": "#/components/schemas/SuggestionType" + }, + { + "type": "null" + } + ] + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + }, + "value": { + "title": "Value" + } + }, + "required": [ + "id", + "record_id", + "question_id", + "value", + "score", + "agent", + "type", + "inserted_at", + "updated_at" + ], + "title": "SuggestionRead", + "type": "object" + }, + "SuggestionType": { + "enum": [ + "model", + "human", + "selection" + ], + "title": "SuggestionType", + "type": "string" + }, + "SuggestionUpsert": { + "properties": { + "agent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Agent" + }, + "question_id": { + "format": "uuid", + "title": "Question Id", + "type": "string" + }, + "score": { + "anyOf": [ + { + "type": "number" + }, + { + "items": { + "type": "number" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Score" + }, + "type": { + "anyOf": [ + { + "$ref": "#/components/schemas/SuggestionType" + }, + { + "type": "null" + } + ] + }, + "value": { + "title": "Value" + } + }, + "required": [ + "question_id", + "value" + ], + "title": "SuggestionUpsert", + "type": "object" + }, + "Suggestions": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/SuggestionRead" + }, + "title": "Items", + "type": "array" + } + }, + "required": [ + "items" + ], + "title": "Suggestions", + "type": "object" + }, + "Token": { + "description": "Token response model", + "properties": { + "access_token": { + "title": "Access Token", + "type": "string" + }, + "refresh_token": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Refresh Token" + }, + "token_type": { + "default": "bearer", + "title": "Token Type", + "type": "string" + } + }, + "required": [ + "access_token" + ], + "title": "Token", + "type": "object" + }, + "V2RecordStatus": { + "description": "v2 record status. Distinct from v1 RecordStatus: adds `discarded` and maps to its\nown PG enum type (v2_record_status_enum) so v1's record_status_enum is untouched.", + "enum": [ + "pending", + "completed", + "discarded" + ], + "title": "V2RecordStatus", + "type": "string" + } + }, + "securitySchemes": { + "APIKeyHeader": { + "in": "header", + "name": "X-Extralit-Api-Key", + "type": "apiKey" + }, + "HTTPBearer": { + "scheme": "bearer", + "type": "http" + } + } + }, + "info": { + "description": "Extralit Server API v2 (schema-centric)", + "title": "Extralit v2", + "version": "0.6.1" + }, + "openapi": "3.1.0", + "paths": { + "/projection/references/{reference}": { + "get": { + "operationId": "get_reference_projection_projection_references__reference__get", + "parameters": [ + { + "in": "path", + "name": "reference", + "required": true, + "schema": { + "title": "Reference", + "type": "string" + } + }, + { + "description": "Workspace to scope the view (required)", + "in": "query", + "name": "workspace_id", + "required": true, + "schema": { + "description": "Workspace to scope the view (required)", + "format": "uuid", + "title": "Workspace Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectionView" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::MissingDatasetRecordsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ForbiddenOperationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityNotFoundError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityAlreadyExistsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ValidationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "builtins.TypeError" + } + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Get Reference Projection", + "tags": [ + "v2: projection" + ] + } + }, + "/questions/{question_id}": { + "delete": { + "operationId": "delete_question_questions__question_id__delete", + "parameters": [ + { + "in": "path", + "name": "question_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Question Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::MissingDatasetRecordsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ForbiddenOperationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityNotFoundError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityAlreadyExistsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ValidationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "builtins.TypeError" + } + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Delete Question", + "tags": [ + "v2: questions" + ] + }, + "get": { + "operationId": "get_question_questions__question_id__get", + "parameters": [ + { + "in": "path", + "name": "question_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Question Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QuestionRead" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::MissingDatasetRecordsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ForbiddenOperationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityNotFoundError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityAlreadyExistsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ValidationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "builtins.TypeError" + } + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Get Question", + "tags": [ + "v2: questions" + ] + }, + "put": { + "operationId": "update_question_questions__question_id__put", + "parameters": [ + { + "in": "path", + "name": "question_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Question Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QuestionUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QuestionRead" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::MissingDatasetRecordsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ForbiddenOperationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityNotFoundError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityAlreadyExistsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ValidationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "builtins.TypeError" + } + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Update Question", + "tags": [ + "v2: questions" + ] + } + }, + "/records/{record_id}/responses": { + "get": { + "operationId": "get_own_response_records__record_id__responses_get", + "parameters": [ + { + "in": "path", + "name": "record_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Record Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ResponseRead" + }, + { + "type": "null" + } + ], + "title": "Response Get Own Response Records Record Id Responses Get" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::MissingDatasetRecordsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ForbiddenOperationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityNotFoundError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityAlreadyExistsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ValidationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "builtins.TypeError" + } + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Get Own Response", + "tags": [ + "v2: annotation" + ] + }, + "put": { + "operationId": "upsert_response_records__record_id__responses_put", + "parameters": [ + { + "in": "path", + "name": "record_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Record Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResponseUpsert" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResponseRead" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::MissingDatasetRecordsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ForbiddenOperationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityNotFoundError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityAlreadyExistsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ValidationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "builtins.TypeError" + } + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Upsert Response", + "tags": [ + "v2: annotation" + ] + } + }, + "/records/{record_id}/suggestions": { + "get": { + "operationId": "list_suggestions_records__record_id__suggestions_get", + "parameters": [ + { + "in": "path", + "name": "record_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Record Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Suggestions" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::MissingDatasetRecordsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ForbiddenOperationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityNotFoundError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityAlreadyExistsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ValidationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "builtins.TypeError" + } + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "List Suggestions", + "tags": [ + "v2: annotation" + ] + }, + "put": { + "operationId": "upsert_suggestion_records__record_id__suggestions_put", + "parameters": [ + { + "in": "path", + "name": "record_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Record Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuggestionUpsert" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuggestionRead" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::MissingDatasetRecordsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ForbiddenOperationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityNotFoundError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityAlreadyExistsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ValidationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "builtins.TypeError" + } + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Upsert Suggestion", + "tags": [ + "v2: annotation" + ] + } + }, + "/references/{reference}": { + "get": { + "description": "The document's project-level extraction view: all v2 records across every schema in\nthe workspace that share this `reference` (spec \u00a76), grouped per schema.\n\nAn unknown reference returns an empty view (200): the reference is a free-form join\nkey, not an entity, so \"no extractions yet\" is not an error.", + "operationId": "get_reference_view_references__reference__get", + "parameters": [ + { + "in": "path", + "name": "reference", + "required": true, + "schema": { + "title": "Reference", + "type": "string" + } + }, + { + "description": "Workspace to scope the cross-schema view (required)", + "in": "query", + "name": "workspace_id", + "required": true, + "schema": { + "description": "Workspace to scope the cross-schema view (required)", + "format": "uuid", + "title": "Workspace Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReferenceView" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::MissingDatasetRecordsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ForbiddenOperationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityNotFoundError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityAlreadyExistsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ValidationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "builtins.TypeError" + } + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Get Reference View", + "tags": [ + "v2: records" + ] + } + }, + "/schemas": { + "get": { + "operationId": "list_schemas_schemas_get", + "parameters": [ + { + "description": "Workspace to list schemas for (required)", + "in": "query", + "name": "workspace_id", + "required": true, + "schema": { + "description": "Workspace to list schemas for (required)", + "format": "uuid", + "title": "Workspace Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Schemas" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::MissingDatasetRecordsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ForbiddenOperationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityNotFoundError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityAlreadyExistsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ValidationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "builtins.TypeError" + } + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "List Schemas", + "tags": [ + "v2: schemas" + ] + }, + "post": { + "operationId": "create_schema_schemas_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SchemaCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SchemaRead" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::MissingDatasetRecordsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ForbiddenOperationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityNotFoundError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityAlreadyExistsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ValidationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "builtins.TypeError" + } + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Create Schema", + "tags": [ + "v2: schemas" + ] + } + }, + "/schemas/{schema_id}": { + "delete": { + "operationId": "delete_schema_schemas__schema_id__delete", + "parameters": [ + { + "in": "path", + "name": "schema_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Schema Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SchemaRead" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::MissingDatasetRecordsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ForbiddenOperationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityNotFoundError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityAlreadyExistsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ValidationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "builtins.TypeError" + } + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Delete Schema", + "tags": [ + "v2: schemas" + ] + }, + "get": { + "operationId": "get_schema_schemas__schema_id__get", + "parameters": [ + { + "in": "path", + "name": "schema_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Schema Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SchemaRead" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::MissingDatasetRecordsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ForbiddenOperationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityNotFoundError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityAlreadyExistsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ValidationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "builtins.TypeError" + } + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Get Schema", + "tags": [ + "v2: schemas" + ] + }, + "put": { + "operationId": "update_schema_schemas__schema_id__put", + "parameters": [ + { + "in": "path", + "name": "schema_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Schema Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SchemaUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SchemaRead" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::MissingDatasetRecordsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ForbiddenOperationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityNotFoundError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityAlreadyExistsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ValidationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "builtins.TypeError" + } + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Update Schema", + "tags": [ + "v2: schemas" + ] + } + }, + "/schemas/{schema_id}/columns": { + "get": { + "operationId": "get_schema_columns_schemas__schema_id__columns_get", + "parameters": [ + { + "in": "path", + "name": "schema_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Schema Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "type": "object" + }, + "title": "Response Get Schema Columns Schemas Schema Id Columns Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::MissingDatasetRecordsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ForbiddenOperationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityNotFoundError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityAlreadyExistsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ValidationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "builtins.TypeError" + } + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Get Schema Columns", + "tags": [ + "v2: schemas" + ] + } + }, + "/schemas/{schema_id}/questions": { + "get": { + "operationId": "list_questions_schemas__schema_id__questions_get", + "parameters": [ + { + "in": "path", + "name": "schema_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Schema Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Questions" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::MissingDatasetRecordsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ForbiddenOperationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityNotFoundError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityAlreadyExistsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ValidationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "builtins.TypeError" + } + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "List Questions", + "tags": [ + "v2: questions" + ] + }, + "post": { + "operationId": "create_question_schemas__schema_id__questions_post", + "parameters": [ + { + "in": "path", + "name": "schema_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Schema Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QuestionCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QuestionRead" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::MissingDatasetRecordsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ForbiddenOperationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityNotFoundError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityAlreadyExistsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ValidationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "builtins.TypeError" + } + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Create Question", + "tags": [ + "v2: questions" + ] + } + }, + "/schemas/{schema_id}/records": { + "delete": { + "operationId": "delete_schema_records_schemas__schema_id__records_delete", + "parameters": [ + { + "in": "path", + "name": "schema_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Schema Id", + "type": "string" + } + }, + { + "description": "Comma-separated record ids to delete", + "in": "query", + "name": "ids", + "required": true, + "schema": { + "description": "Comma-separated record ids to delete", + "title": "Ids", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::MissingDatasetRecordsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ForbiddenOperationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityNotFoundError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityAlreadyExistsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ValidationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "builtins.TypeError" + } + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Delete Schema Records", + "tags": [ + "v2: records" + ] + }, + "get": { + "operationId": "list_schema_records_schemas__schema_id__records_get", + "parameters": [ + { + "in": "path", + "name": "schema_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Schema Id", + "type": "string" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 1000, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "status", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/V2RecordStatus" + }, + { + "type": "null" + } + ], + "title": "Status" + } + }, + { + "in": "query", + "name": "reference", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reference" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Records" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::MissingDatasetRecordsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ForbiddenOperationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityNotFoundError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityAlreadyExistsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ValidationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "builtins.TypeError" + } + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "List Schema Records", + "tags": [ + "v2: records" + ] + } + }, + "/schemas/{schema_id}/records:bulk-upsert": { + "post": { + "operationId": "bulk_upsert_schema_records_schemas__schema_id__records_bulk_upsert_post", + "parameters": [ + { + "in": "path", + "name": "schema_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Schema Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecordsBulkUpsert" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Records" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::MissingDatasetRecordsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ForbiddenOperationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityNotFoundError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityAlreadyExistsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ValidationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "builtins.TypeError" + } + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Bulk Upsert Schema Records", + "tags": [ + "v2: records" + ] + } + }, + "/schemas/{schema_id}/records:search": { + "post": { + "description": "Full-text (BM25) + scalar-filter search over a schema's records.\n\nLance supplies matching record ids and scores; payloads are hydrated from Postgres\n(the source of truth) and returned in the engine's hit order. `total` is the engine's\ntotal match count, which may exceed the returned page.", + "operationId": "search_schema_records_schemas__schema_id__records_search_post", + "parameters": [ + { + "in": "path", + "name": "schema_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Schema Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecordSearchQuery" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Records" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::MissingDatasetRecordsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ForbiddenOperationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityNotFoundError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityAlreadyExistsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ValidationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "builtins.TypeError" + } + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Search Schema Records", + "tags": [ + "v2: records" + ] + } + }, + "/schemas/{schema_id}/versions": { + "get": { + "operationId": "list_schema_versions_schemas__schema_id__versions_get", + "parameters": [ + { + "in": "path", + "name": "schema_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Schema Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/SchemaVersionRead" + }, + "title": "Response List Schema Versions Schemas Schema Id Versions Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::MissingDatasetRecordsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ForbiddenOperationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityNotFoundError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityAlreadyExistsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ValidationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "builtins.TypeError" + } + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "List Schema Versions", + "tags": [ + "v2: schemas" + ] + }, + "post": { + "operationId": "publish_schema_version_schemas__schema_id__versions_post", + "parameters": [ + { + "in": "path", + "name": "schema_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Schema Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SchemaVersionCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SchemaVersionRead" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::MissingDatasetRecordsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ForbiddenOperationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityNotFoundError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityAlreadyExistsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ValidationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "builtins.TypeError" + } + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Publish Schema Version", + "tags": [ + "v2: schemas" + ] + } + }, + "/schemas/{schema_id}/versions/{version}": { + "get": { + "operationId": "get_schema_version_schemas__schema_id__versions__version__get", + "parameters": [ + { + "in": "path", + "name": "schema_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Schema Id", + "type": "string" + } + }, + { + "in": "path", + "name": "version", + "required": true, + "schema": { + "title": "Version", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SchemaVersionRead" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::MissingDatasetRecordsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ForbiddenOperationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityNotFoundError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityAlreadyExistsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ValidationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "builtins.TypeError" + } + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Get Schema Version", + "tags": [ + "v2: schemas" + ] + } + }, + "/schemas/{schema_id}:rebuild-index": { + "post": { + "description": "Drop and repopulate the schema's Lance table from Postgres (the recovery path).\n\nUnlike the write-time sync hooks, this surfaces engine errors to the caller \u2014 the\noperator explicitly asked to rebuild. For large schemas the rebuild may take tens of\nseconds; consider running as a background job (via the CLI) if timeouts are a concern.", + "operationId": "rebuild_schema_index_schemas__schema_id__rebuild_index_post", + "parameters": [ + { + "in": "path", + "name": "schema_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Schema Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "integer" + }, + "title": "Response Rebuild Schema Index Schemas Schema Id Rebuild Index Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::MissingDatasetRecordsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ForbiddenOperationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityNotFoundError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityAlreadyExistsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ValidationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "builtins.TypeError" + } + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Rebuild Schema Index", + "tags": [ + "v2: records" + ] + } + }, + "/token": { + "post": { + "operationId": "create_token_token_post", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_create_token_token_post" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Token" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::MissingDatasetRecordsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ForbiddenOperationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityNotFoundError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityAlreadyExistsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ValidationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "builtins.TypeError" + } + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "Create Token", + "tags": [ + "Authentication" + ] + } + }, + "/token/refresh": { + "post": { + "description": "Refresh an access token using a valid refresh token.\nThis endpoint does not require database access, improving reliability.", + "operationId": "refresh_token_token_refresh_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RefreshTokenRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Token" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::MissingDatasetRecordsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ForbiddenOperationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityNotFoundError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::EntityAlreadyExistsError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "extralit.api.errors::ValidationError", + "params": { + "extra": "error parameters" + } + } + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "example": { + "detail": { + "code": "builtins.TypeError" + } + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "Refresh Token", + "tags": [ + "Authentication" + ] + } + } + } +} From ddf9e799d8be248ec928d5ab3c5508746a917f2d Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Fri, 10 Jul 2026 06:36:14 +0000 Subject: [PATCH 03/29] feat(v2-ui): schema/question domain entities + widget-selection mapping --- .../v2/domain/entities/question/Question.ts | 37 +++++++++++++++++ .../entities/review/widget-mapping.test.ts | 41 +++++++++++++++++++ .../domain/entities/review/widget-mapping.ts | 23 +++++++++++ .../v2/domain/entities/schema/ColumnMeta.ts | 15 +++++++ .../v2/domain/entities/schema/Schema.ts | 12 ++++++ .../entities/schema/SchemaVersion.test.ts | 22 ++++++++++ .../domain/entities/schema/SchemaVersion.ts | 16 ++++++++ 7 files changed, 166 insertions(+) create mode 100644 extralit-frontend/v2/domain/entities/question/Question.ts create mode 100644 extralit-frontend/v2/domain/entities/review/widget-mapping.test.ts create mode 100644 extralit-frontend/v2/domain/entities/review/widget-mapping.ts create mode 100644 extralit-frontend/v2/domain/entities/schema/ColumnMeta.ts create mode 100644 extralit-frontend/v2/domain/entities/schema/Schema.ts create mode 100644 extralit-frontend/v2/domain/entities/schema/SchemaVersion.test.ts create mode 100644 extralit-frontend/v2/domain/entities/schema/SchemaVersion.ts diff --git a/extralit-frontend/v2/domain/entities/question/Question.ts b/extralit-frontend/v2/domain/entities/question/Question.ts new file mode 100644 index 000000000..9b7fab268 --- /dev/null +++ b/extralit-frontend/v2/domain/entities/question/Question.ts @@ -0,0 +1,37 @@ +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); + } +} diff --git a/extralit-frontend/v2/domain/entities/review/widget-mapping.test.ts b/extralit-frontend/v2/domain/entities/review/widget-mapping.test.ts new file mode 100644 index 000000000..b2d2756e5 --- /dev/null +++ b/extralit-frontend/v2/domain/entities/review/widget-mapping.test.ts @@ -0,0 +1,41 @@ +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"); + }); +}); diff --git a/extralit-frontend/v2/domain/entities/review/widget-mapping.ts b/extralit-frontend/v2/domain/entities/review/widget-mapping.ts new file mode 100644 index 000000000..206477ed6 --- /dev/null +++ b/extralit-frontend/v2/domain/entities/review/widget-mapping.ts @@ -0,0 +1,23 @@ +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); diff --git a/extralit-frontend/v2/domain/entities/schema/ColumnMeta.ts b/extralit-frontend/v2/domain/entities/schema/ColumnMeta.ts new file mode 100644 index 000000000..579643cc5 --- /dev/null +++ b/extralit-frontend/v2/domain/entities/schema/ColumnMeta.ts @@ -0,0 +1,15 @@ +// 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 + ) {} +} diff --git a/extralit-frontend/v2/domain/entities/schema/Schema.ts b/extralit-frontend/v2/domain/entities/schema/Schema.ts new file mode 100644 index 000000000..a85ee1ea8 --- /dev/null +++ b/extralit-frontend/v2/domain/entities/schema/Schema.ts @@ -0,0 +1,12 @@ +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 + ) {} +} diff --git a/extralit-frontend/v2/domain/entities/schema/SchemaVersion.test.ts b/extralit-frontend/v2/domain/entities/schema/SchemaVersion.test.ts new file mode 100644 index 000000000..4f900db0f --- /dev/null +++ b/extralit-frontend/v2/domain/entities/schema/SchemaVersion.test.ts @@ -0,0 +1,22 @@ +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(); + }); +}); diff --git a/extralit-frontend/v2/domain/entities/schema/SchemaVersion.ts b/extralit-frontend/v2/domain/entities/schema/SchemaVersion.ts new file mode 100644 index 000000000..69325dfd5 --- /dev/null +++ b/extralit-frontend/v2/domain/entities/schema/SchemaVersion.ts @@ -0,0 +1,16 @@ +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); + } +} From 7c4b0ecf95d4e68a556c504b06ad099851273641 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Sat, 11 Jul 2026 01:21:15 -0700 Subject: [PATCH 04/29] docs --- .../2026-07-10-v2-frontend-vertical-slice.md | 4805 +++++++++++++++++ 1 file changed, 4805 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-10-v2-frontend-vertical-slice.md 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). From d62bd009b51e3bb6386342183f22132b98775131 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Sun, 12 Jul 2026 08:01:57 +0000 Subject: [PATCH 05/29] feat(v2-ui): v2 DI container, SchemaRepository, schemas storage and first use-cases --- extralit-frontend/plugins/3.di.ts | 4 +- extralit-frontend/v2/di/di.ts | 29 +++++++ extralit-frontend/v2/di/index.ts | 1 + .../usecases/get-schema-settings-use-case.ts | 23 ++++++ .../usecases/get-schemas-use-case.test.ts | 22 +++++ .../domain/usecases/get-schemas-use-case.ts | 16 ++++ .../repositories/SchemaRepository.test.ts | 81 +++++++++++++++++++ .../repositories/SchemaRepository.ts | 78 ++++++++++++++++++ .../infrastructure/storage/SchemasStorage.ts | 23 ++++++ 9 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 extralit-frontend/v2/di/di.ts create mode 100644 extralit-frontend/v2/di/index.ts create mode 100644 extralit-frontend/v2/domain/usecases/get-schema-settings-use-case.ts create mode 100644 extralit-frontend/v2/domain/usecases/get-schemas-use-case.test.ts create mode 100644 extralit-frontend/v2/domain/usecases/get-schemas-use-case.ts create mode 100644 extralit-frontend/v2/infrastructure/repositories/SchemaRepository.test.ts create mode 100644 extralit-frontend/v2/infrastructure/repositories/SchemaRepository.ts create mode 100644 extralit-frontend/v2/infrastructure/storage/SchemasStorage.ts diff --git a/extralit-frontend/plugins/3.di.ts b/extralit-frontend/plugins/3.di.ts index d9bf71046..c467884bd 100644 --- a/extralit-frontend/plugins/3.di.ts +++ b/extralit-frontend/plugins/3.di.ts @@ -1,8 +1,10 @@ import { defineNuxtPlugin } from "#app"; import { loadDependencyContainer } from "~/v1/di"; +import { loadV2DependencyContainer } from "~/v2/di"; // Registers the ts-injecty container. Ordered last (3.) so $auth (1.) and $axios (2.) -// are available when repositories resolve. +// are available when repositories resolve. v2 loads after v1 into the same container. export default defineNuxtPlugin((nuxtApp) => { loadDependencyContainer(nuxtApp as never); + loadV2DependencyContainer(nuxtApp as never); }); diff --git a/extralit-frontend/v2/di/di.ts b/extralit-frontend/v2/di/di.ts new file mode 100644 index 000000000..45534a2a0 --- /dev/null +++ b/extralit-frontend/v2/di/di.ts @@ -0,0 +1,29 @@ +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); +}; diff --git a/extralit-frontend/v2/di/index.ts b/extralit-frontend/v2/di/index.ts new file mode 100644 index 000000000..f0d790277 --- /dev/null +++ b/extralit-frontend/v2/di/index.ts @@ -0,0 +1 @@ +export * from "./di"; diff --git a/extralit-frontend/v2/domain/usecases/get-schema-settings-use-case.ts b/extralit-frontend/v2/domain/usecases/get-schema-settings-use-case.ts new file mode 100644 index 000000000..08246fd6c --- /dev/null +++ b/extralit-frontend/v2/domain/usecases/get-schema-settings-use-case.ts @@ -0,0 +1,23 @@ +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 }; + } +} diff --git a/extralit-frontend/v2/domain/usecases/get-schemas-use-case.test.ts b/extralit-frontend/v2/domain/usecases/get-schemas-use-case.test.ts new file mode 100644 index 000000000..20cf654ce --- /dev/null +++ b/extralit-frontend/v2/domain/usecases/get-schemas-use-case.test.ts @@ -0,0 +1,22 @@ +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]); + }); +}); diff --git a/extralit-frontend/v2/domain/usecases/get-schemas-use-case.ts b/extralit-frontend/v2/domain/usecases/get-schemas-use-case.ts new file mode 100644 index 000000000..483def3e9 --- /dev/null +++ b/extralit-frontend/v2/domain/usecases/get-schemas-use-case.ts @@ -0,0 +1,16 @@ +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; + } +} diff --git a/extralit-frontend/v2/infrastructure/repositories/SchemaRepository.test.ts b/extralit-frontend/v2/infrastructure/repositories/SchemaRepository.test.ts new file mode 100644 index 000000000..46e78efa4 --- /dev/null +++ b/extralit-frontend/v2/infrastructure/repositories/SchemaRepository.test.ts @@ -0,0 +1,81 @@ +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); + }); +}); diff --git a/extralit-frontend/v2/infrastructure/repositories/SchemaRepository.ts b/extralit-frontend/v2/infrastructure/repositories/SchemaRepository.ts new file mode 100644 index 000000000..aa8d580cc --- /dev/null +++ b/extralit-frontend/v2/infrastructure/repositories/SchemaRepository.ts @@ -0,0 +1,78 @@ +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); + } +} diff --git a/extralit-frontend/v2/infrastructure/storage/SchemasStorage.ts b/extralit-frontend/v2/infrastructure/storage/SchemasStorage.ts new file mode 100644 index 000000000..385d9b1d8 --- /dev/null +++ b/extralit-frontend/v2/infrastructure/storage/SchemasStorage.ts @@ -0,0 +1,23 @@ +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 }; +}; From c48b9463ecd5831558721b9ba35586a26384d77a Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Sun, 12 Jul 2026 08:04:00 +0000 Subject: [PATCH 06/29] fix(v2-ui): tighten dtype editor matching and document span exclusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roborev job 131 (Low): dtypeDefaultEditor matched pandas interval[...] as a number via the broad 'int' prefix — now requires a digit (int64/uint8/float32). QuestionType intentionally omits the API's 'span' variant (rejected on write); documented at the type and cast site. --- extralit-frontend/v2/domain/entities/question/Question.ts | 4 ++++ .../v2/domain/entities/review/widget-mapping.test.ts | 2 ++ extralit-frontend/v2/domain/entities/review/widget-mapping.ts | 4 +++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/extralit-frontend/v2/domain/entities/question/Question.ts b/extralit-frontend/v2/domain/entities/question/Question.ts index 9b7fab268..1331cbabc 100644 --- a/extralit-frontend/v2/domain/entities/question/Question.ts +++ b/extralit-frontend/v2/domain/entities/question/Question.ts @@ -1,3 +1,7 @@ +// The server's QuestionType enum also includes "span", but span questions are rejected +// on write (spec §7) and are out of scope for the v2 review slice, so the domain type +// omits it. SchemaRepository.toQuestion casts the API value knowing "span" will never +// reach this entity in practice. export type QuestionType = "text" | "rating" | "label_selection" | "multi_label_selection" | "ranking" | "table"; export interface QuestionOption { diff --git a/extralit-frontend/v2/domain/entities/review/widget-mapping.test.ts b/extralit-frontend/v2/domain/entities/review/widget-mapping.test.ts index b2d2756e5..76032ccd3 100644 --- a/extralit-frontend/v2/domain/entities/review/widget-mapping.test.ts +++ b/extralit-frontend/v2/domain/entities/review/widget-mapping.test.ts @@ -11,6 +11,8 @@ describe("dtypeDefaultEditor", () => { ["bool", "checkbox"], ["datetime64[ns]", "date"], ["object", "text"], // unknown dtype falls back to text + ["interval[int64]", "text"], // "int" prefix must not classify interval as number + ["uint8", "number"], ])("maps dtype %s to %s", (dtype, editor) => { expect(dtypeDefaultEditor(dtype)).toBe(editor); }); diff --git a/extralit-frontend/v2/domain/entities/review/widget-mapping.ts b/extralit-frontend/v2/domain/entities/review/widget-mapping.ts index 206477ed6..6975b0634 100644 --- a/extralit-frontend/v2/domain/entities/review/widget-mapping.ts +++ b/extralit-frontend/v2/domain/entities/review/widget-mapping.ts @@ -7,7 +7,9 @@ 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"; + // Require a digit after int/float so numpy widths (int64, uint8, float32) match but + // pandas `interval[...]` does not fall through to the number editor. + if (/^u?int\d/.test(dtype) || /^float\d/.test(dtype)) return "number"; if (dtype === "bool") return "checkbox"; if (dtype.startsWith("datetime")) return "date"; return "text"; // str and anything unknown From 4b7d7bb678c3d136f896111a9fe572674ecb32cc Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Sun, 12 Jul 2026 08:05:10 +0000 Subject: [PATCH 07/29] test(v2-ui): cover getSchema and GetSchemaSettingsUseCase fan-out Roborev job 133 (Low): add SchemaRepository.getSchema URL+mapping assertion and a GetSchemaSettingsUseCase test verifying all three repo calls fire and the { schema, versions, questions } shape is returned. --- .../get-schema-settings-use-case.test.ts | 27 +++++++++++++++++++ .../repositories/SchemaRepository.test.ts | 12 +++++++++ 2 files changed, 39 insertions(+) create mode 100644 extralit-frontend/v2/domain/usecases/get-schema-settings-use-case.test.ts diff --git a/extralit-frontend/v2/domain/usecases/get-schema-settings-use-case.test.ts b/extralit-frontend/v2/domain/usecases/get-schema-settings-use-case.test.ts new file mode 100644 index 000000000..41569f783 --- /dev/null +++ b/extralit-frontend/v2/domain/usecases/get-schema-settings-use-case.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; +import { GetSchemaSettingsUseCase } from "./get-schema-settings-use-case"; +import { Schema } from "../entities/schema/Schema"; +import { SchemaVersion } from "../entities/schema/SchemaVersion"; +import { Question } from "../entities/question/Question"; + +const SCHEMA = new Schema("s-1", "sample_size", "published", "w-1", "v-1", {}, "2026-01-01", "2026-01-01"); +const VERSION = new SchemaVersion("v-1", "s-1", 1, [], {}, "2026-01-01"); +const QUESTION = new Question("q-1", "s-1", "label", "Label", null, "label_selection", ["label"], {}, true); + +describe("GetSchemaSettingsUseCase", () => { + it("fans out all three repository calls and returns the assembled settings shape", async () => { + const repository = { + getSchema: vi.fn(async () => SCHEMA), + getVersions: vi.fn(async () => [VERSION]), + getQuestions: vi.fn(async () => [QUESTION]), + }; + const useCase = new GetSchemaSettingsUseCase(repository as never); + + const result = await useCase.execute("s-1"); + + expect(repository.getSchema).toHaveBeenCalledWith("s-1"); + expect(repository.getVersions).toHaveBeenCalledWith("s-1"); + expect(repository.getQuestions).toHaveBeenCalledWith("s-1"); + expect(result).toEqual({ schema: SCHEMA, versions: [VERSION], questions: [QUESTION] }); + }); +}); diff --git a/extralit-frontend/v2/infrastructure/repositories/SchemaRepository.test.ts b/extralit-frontend/v2/infrastructure/repositories/SchemaRepository.test.ts index 46e78efa4..e0a61e598 100644 --- a/extralit-frontend/v2/infrastructure/repositories/SchemaRepository.test.ts +++ b/extralit-frontend/v2/infrastructure/repositories/SchemaRepository.test.ts @@ -28,6 +28,18 @@ describe("SchemaRepository", () => { expect(schemas[0].currentVersionId).toBe("v-1"); }); + it("fetches a single schema and maps it to a domain entity", async () => { + const axios = axiosMock(() => BACKEND_SCHEMA); + const repository = new SchemaRepository(axios); + + const schema = await repository.getSchema("s-1"); + + expect(axios.get).toHaveBeenCalledWith("/v2/schemas/s-1"); + expect(schema.id).toBe("s-1"); + expect(schema.name).toBe("sample_size"); + expect(schema.currentVersionId).toBe("v-1"); + }); + it("maps versions including columns_cache to ColumnMeta", async () => { const axios = axiosMock(() => [ { From 973838a0eb6b128b6c8600ab618cecb4ee9b6327 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Sun, 12 Jul 2026 08:07:30 +0000 Subject: [PATCH 08/29] feat(v2-ui): records domain, V2RecordRepository, search criteria and record use-cases --- extralit-frontend/v2/di/di.ts | 9 +++ .../v2/domain/entities/record/RecordsPage.ts | 10 ++++ .../v2/domain/entities/record/V2Record.ts | 17 ++++++ .../entities/search/SearchCriteria.test.ts | 28 ++++++++++ .../domain/entities/search/SearchCriteria.ts | 26 +++++++++ .../usecases/get-schema-records-use-case.ts | 10 ++++ .../usecases/rebuild-schema-index-use-case.ts | 9 +++ .../usecases/search-records-use-case.ts | 11 ++++ .../repositories/V2RecordRepository.test.ts | 55 +++++++++++++++++++ .../repositories/V2RecordRepository.ts | 53 ++++++++++++++++++ 10 files changed, 228 insertions(+) create mode 100644 extralit-frontend/v2/domain/entities/record/RecordsPage.ts create mode 100644 extralit-frontend/v2/domain/entities/record/V2Record.ts create mode 100644 extralit-frontend/v2/domain/entities/search/SearchCriteria.test.ts create mode 100644 extralit-frontend/v2/domain/entities/search/SearchCriteria.ts create mode 100644 extralit-frontend/v2/domain/usecases/get-schema-records-use-case.ts create mode 100644 extralit-frontend/v2/domain/usecases/rebuild-schema-index-use-case.ts create mode 100644 extralit-frontend/v2/domain/usecases/search-records-use-case.ts create mode 100644 extralit-frontend/v2/infrastructure/repositories/V2RecordRepository.test.ts create mode 100644 extralit-frontend/v2/infrastructure/repositories/V2RecordRepository.ts diff --git a/extralit-frontend/v2/di/di.ts b/extralit-frontend/v2/di/di.ts index 45534a2a0..4937a160b 100644 --- a/extralit-frontend/v2/di/di.ts +++ b/extralit-frontend/v2/di/di.ts @@ -4,9 +4,13 @@ import Container, { register } from "ts-injecty"; import { useAxiosExtension } from "@/v1/infrastructure/services/useAxiosExtension"; import { SchemaRepository } from "~/v2/infrastructure/repositories/SchemaRepository"; +import { V2RecordRepository } from "~/v2/infrastructure/repositories/V2RecordRepository"; 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"; +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"; type NuxtAppLike = { $axios: AxiosInstance; @@ -23,6 +27,11 @@ export const loadV2DependencyContainer = (nuxtApp: NuxtAppLike) => { register(SchemaRepository).withDependency(useAxios).build(), register(GetSchemasUseCase).withDependencies(SchemaRepository, useSchemas).build(), register(GetSchemaSettingsUseCase).withDependency(SchemaRepository).build(), + + register(V2RecordRepository).withDependency(useAxios).build(), + register(GetSchemaRecordsUseCase).withDependency(V2RecordRepository).build(), + register(SearchRecordsUseCase).withDependency(V2RecordRepository).build(), + register(RebuildSchemaIndexUseCase).withDependency(V2RecordRepository).build(), ]; Container.register(dependencies); diff --git a/extralit-frontend/v2/domain/entities/record/RecordsPage.ts b/extralit-frontend/v2/domain/entities/record/RecordsPage.ts new file mode 100644 index 000000000..79bc3a900 --- /dev/null +++ b/extralit-frontend/v2/domain/entities/record/RecordsPage.ts @@ -0,0 +1,10 @@ +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 + ) {} +} diff --git a/extralit-frontend/v2/domain/entities/record/V2Record.ts b/extralit-frontend/v2/domain/entities/record/V2Record.ts new file mode 100644 index 000000000..670e680c2 --- /dev/null +++ b/extralit-frontend/v2/domain/entities/record/V2Record.ts @@ -0,0 +1,17 @@ +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 + ) {} +} diff --git a/extralit-frontend/v2/domain/entities/search/SearchCriteria.test.ts b/extralit-frontend/v2/domain/entities/search/SearchCriteria.test.ts new file mode 100644 index 000000000..f7568f685 --- /dev/null +++ b/extralit-frontend/v2/domain/entities/search/SearchCriteria.test.ts @@ -0,0 +1,28 @@ +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 }]); + }); +}); diff --git a/extralit-frontend/v2/domain/entities/search/SearchCriteria.ts b/extralit-frontend/v2/domain/entities/search/SearchCriteria.ts new file mode 100644 index 000000000..e6685340e --- /dev/null +++ b/extralit-frontend/v2/domain/entities/search/SearchCriteria.ts @@ -0,0 +1,26 @@ +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, + }; + } +} diff --git a/extralit-frontend/v2/domain/usecases/get-schema-records-use-case.ts b/extralit-frontend/v2/domain/usecases/get-schema-records-use-case.ts new file mode 100644 index 000000000..9f45bf72e --- /dev/null +++ b/extralit-frontend/v2/domain/usecases/get-schema-records-use-case.ts @@ -0,0 +1,10 @@ +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); + } +} diff --git a/extralit-frontend/v2/domain/usecases/rebuild-schema-index-use-case.ts b/extralit-frontend/v2/domain/usecases/rebuild-schema-index-use-case.ts new file mode 100644 index 000000000..529c4cd99 --- /dev/null +++ b/extralit-frontend/v2/domain/usecases/rebuild-schema-index-use-case.ts @@ -0,0 +1,9 @@ +import { V2RecordRepository } from "~/v2/infrastructure/repositories/V2RecordRepository"; + +export class RebuildSchemaIndexUseCase { + constructor(private readonly recordRepository: V2RecordRepository) {} + + execute(schemaId: string): Promise { + return this.recordRepository.rebuildIndex(schemaId); + } +} diff --git a/extralit-frontend/v2/domain/usecases/search-records-use-case.ts b/extralit-frontend/v2/domain/usecases/search-records-use-case.ts new file mode 100644 index 000000000..e0369479f --- /dev/null +++ b/extralit-frontend/v2/domain/usecases/search-records-use-case.ts @@ -0,0 +1,11 @@ +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); + } +} diff --git a/extralit-frontend/v2/infrastructure/repositories/V2RecordRepository.test.ts b/extralit-frontend/v2/infrastructure/repositories/V2RecordRepository.test.ts new file mode 100644 index 000000000..bf5a71a81 --- /dev/null +++ b/extralit-frontend/v2/infrastructure/repositories/V2RecordRepository.test.ts @@ -0,0 +1,55 @@ +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"); + }); +}); diff --git a/extralit-frontend/v2/infrastructure/repositories/V2RecordRepository.ts b/extralit-frontend/v2/infrastructure/repositories/V2RecordRepository.ts new file mode 100644 index 000000000..1ff8b1106 --- /dev/null +++ b/extralit-frontend/v2/infrastructure/repositories/V2RecordRepository.ts @@ -0,0 +1,53 @@ +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; + } +} From d8747784a4ea539c2e15cd9a6269bb2f19e842cf Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Sun, 12 Jul 2026 08:12:50 +0000 Subject: [PATCH 09/29] feat(v2-ui): /schemas list page with home nav sibling and schemas.* i18n --- extralit-frontend/pages/index.vue | 59 ++++++++-------- extralit-frontend/pages/schemas/index.test.ts | 43 ++++++++++++ extralit-frontend/pages/schemas/index.vue | 69 +++++++++++++++++++ .../pages/schemas/useSchemasViewModel.ts | 35 ++++++++++ extralit-frontend/translation/en.js | 25 +++++++ 5 files changed, 204 insertions(+), 27 deletions(-) create mode 100644 extralit-frontend/pages/schemas/index.test.ts create mode 100644 extralit-frontend/pages/schemas/index.vue create mode 100644 extralit-frontend/pages/schemas/useSchemasViewModel.ts diff --git a/extralit-frontend/pages/index.vue b/extralit-frontend/pages/index.vue index aba31c23e..fd4de5381 100644 --- a/extralit-frontend/pages/index.vue +++ b/extralit-frontend/pages/index.vue @@ -1,7 +1,7 @@