From 6191e443507a74ca8b18da6f70c1b374413c98f3 Mon Sep 17 00:00:00 2001 From: Egor Poderiagin Date: Sat, 24 Oct 2026 23:01:22 +0700 Subject: [PATCH 1/3] refactor: standartization gql with skill --- docs/deployment.md | 4 +- .../specs/exercise-api/spec.md | 47 ++- .../follow-graphql-conventions/.openspec.yaml | 2 + .../follow-graphql-conventions/design.md | 241 +++++++++++ .../follow-graphql-conventions/proposal.md | 127 ++++++ .../specs/exercise-api/spec.md | 148 +++++++ .../specs/typing-practice/spec.md | 57 +++ .../follow-graphql-conventions/tasks.md | 149 +++++++ .../management/commands/load_translations.py | 4 +- src/backend/exercises/models.py | 7 +- src/backend/exercises/schema.py | 328 +++++++-------- src/backend/exercises/tests/test_schema.py | 253 +++++++++--- .../tests/test_translation_commands.py | 4 +- .../tests/test_translation_fixture.py | 14 +- .../tests/test_translation_schema.py | 377 ++++++++++++------ .../exercises/translation/providers.py | 6 +- src/backend/typelearn/graphql.py | 78 ++++ src/backend/typelearn/settings.py | 11 +- src/backend/typelearn/tests/__init__.py | 0 src/backend/typelearn/tests/test_graphql.py | 111 ++++++ src/backend/typelearn/urls.py | 4 +- src/frontend/e2e/fixtures.js | 26 +- src/frontend/e2e/translation.spec.js | 6 +- src/frontend/src/components/SentenceView.vue | 2 +- .../__tests__/SentenceView.i18n.test.js | 11 +- .../__tests__/TranslationPanel.test.js | 20 +- .../src/stores/__tests__/exercise.test.js | 27 +- .../src/stores/__tests__/translation.test.js | 27 +- src/frontend/src/stores/exercise.js | 2 +- src/frontend/src/stores/translation.js | 28 +- 30 files changed, 1710 insertions(+), 411 deletions(-) create mode 100644 openspec/changes/follow-graphql-conventions/.openspec.yaml create mode 100644 openspec/changes/follow-graphql-conventions/design.md create mode 100644 openspec/changes/follow-graphql-conventions/proposal.md create mode 100644 openspec/changes/follow-graphql-conventions/specs/exercise-api/spec.md create mode 100644 openspec/changes/follow-graphql-conventions/specs/typing-practice/spec.md create mode 100644 openspec/changes/follow-graphql-conventions/tasks.md create mode 100644 src/backend/typelearn/graphql.py create mode 100644 src/backend/typelearn/tests/__init__.py create mode 100644 src/backend/typelearn/tests/test_graphql.py diff --git a/docs/deployment.md b/docs/deployment.md index 776053a..6e9b0dc 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -165,7 +165,9 @@ The frontend never downloads the whole catalog, however large it is: each page load asks for a random deck of 200 (`deck(size: 200)`), and a new deck is drawn when the learner finishes one. The server caps a deck at `EXERCISE_DECK_MAX_SIZE` (default 500), settable under `env:` like any other -backend variable. +backend variable. A client paging through `exercises` with +`pagination: { offset, limit }` gets at most `STRAWBERRY_PAGINATION_MAX_LIMIT` +(default 100) per page. ### Translations diff --git a/openspec/changes/add-phrase-translations/specs/exercise-api/spec.md b/openspec/changes/add-phrase-translations/specs/exercise-api/spec.md index 0dc97a6..8c272d5 100644 --- a/openspec/changes/add-phrase-translations/specs/exercise-api/spec.md +++ b/openspec/changes/add-phrase-translations/specs/exercise-api/spec.md @@ -46,37 +46,58 @@ accepts. ```graphql enum TranslationRatingValue { UP DOWN NONE } -type Mutation { - rateTranslation(translationId: ID!, clientId: String!, value: TranslationRatingValue!): Translation! - proposeTranslation(exerciseId: ID!, language: String!, text: String!, clientId: String!): ProposeTranslationResult! +input OneToManyInput { set: ID } + +input TranslationRatingInput { + translation: OneToManyInput! + clientId: String! + value: TranslationRatingValue! +} + +input TranslationCreateInput { + exercise: OneToManyInput! + language: String! + text: String! + clientId: String +} + +type TranslationMutations { + rateTranslation(data: TranslationRatingInput!): Translation! + proposeTranslation(data: TranslationCreateInput!): Translation! } -type ProposeTranslationResult { accepted: Boolean! } +type Mutation { + translation: TranslationMutations! +} ``` `clientId` is an opaque random identifier that the browser generates and keeps. It carries no authority beyond deduplicating ratings and scoping rate limits. `rateTranslation` with `NONE` withdraws the caller's rating, and it applies only -to published translations. `proposeTranslation` stores a pending suggestion. -Invalid input (unknown ids, unsupported language, blank or over-long text, -malformed client id, a duplicate of an existing translation) SHALL return a -GraphQL error with a stable, machine-readable code and store nothing. +to published translations. `proposeTranslation` stores a pending suggestion and +returns it. Invalid input (unknown ids, unsupported language, blank or +over-long text, malformed or missing client id, a duplicate of an existing +translation) SHALL return a GraphQL error with a stable, machine-readable code +and store nothing. #### Scenario: Rating is idempotent per client -- **WHEN** the same `clientId` sends `rateTranslation(..., value: UP)` twice +- **WHEN** the same `clientId` sends `translation { rateTranslation(data: {..., value: UP}) }` twice - **THEN** the translation's `upVotes` increased by one in total, not two #### Scenario: Rating an unpublished translation - **WHEN** a client rates a pending or rejected translation -- **THEN** the response is an error and no rating is stored +- **THEN** the response is exactly the error an id no translation has gets, + so it does not reveal that the hidden translation exists, and no rating is + stored #### Scenario: Proposal stored as pending - **WHEN** a client proposes a valid new translation -- **THEN** `accepted` is true and a pending learner translation exists, which - the `translation` field does not return +- **THEN** the mutation returns the new translation, with origin `LEARNER`, + and it is stored as pending, which the `translation` field does not return #### Scenario: Over-long proposal -- **WHEN** a client proposes text longer than the configured maximum +- **WHEN** a client proposes text longer than the longest translation the + catalog stores - **THEN** the response is an error with a length code and nothing is stored ### Requirement: Write rate limits diff --git a/openspec/changes/follow-graphql-conventions/.openspec.yaml b/openspec/changes/follow-graphql-conventions/.openspec.yaml new file mode 100644 index 0000000..1f5d280 --- /dev/null +++ b/openspec/changes/follow-graphql-conventions/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-24 diff --git a/openspec/changes/follow-graphql-conventions/design.md b/openspec/changes/follow-graphql-conventions/design.md new file mode 100644 index 0000000..4cd6a3f --- /dev/null +++ b/openspec/changes/follow-graphql-conventions/design.md @@ -0,0 +1,241 @@ +## Context + +See proposal.md - Why. The whole GraphQL layer is `exercises/schema.py`. The +schema has no authentication, and its only writes are the two anonymous +translation mutations. They refuse bad input with a `GraphQLError` carrying a +stable `extensions.code`, which the frontend maps to a message in the learner's +language (`KNOWN_ERRORS` in `src/frontend/src/stores/translation.js`; any other +code gets a generic message). `MUTATIONS_DEFAULT_HANDLE_ERRORS` is not set, so +Strawberry-Django's `OperationInfo` unions are not in use. + +Measured against a real database, `{ deck(size: 5) { id audioUrl } }` runs six +queries: one for the deck, then one per row for the deferred `original_audio` +column. + +## Goals / Non-Goals + +**Goals:** +- Every refusal the API makes carries a code. +- Each fact the schema depends on (the origins, the maximum text length, what + makes a proposal invalid) is declared once, on the model. +- The schema follows the Strawberry-Django conventions literally where they + apply: standard pagination, model inputs through `data`, a mutation + namespace, and a create that returns its model's type. The two breaking + changes this causes are made together with the frontend. + +**Non-Goals:** +- Adopting `OperationInfo` / `handle_django_errors`. It would change every + mutation's return type into a union and break the frontend's error handling, + for no behavioural gain. The conventions themselves say to follow the repo's + existing error pattern when that setting is not adopted, and here that + pattern is the coded `GraphQLError`. +- The frontend's own `MAX_TRANSLATION_LENGTH = 500`, a client-side pre-check + mirroring the column. It is a separate codebase's copy, and the server + refuses anything it lets through. + +## Decisions + +**`audioUrl` declares its column with an `only=` hint.** The resolver becomes +`@strawberry_django.field(only=['original_audio'])`. The optimizer then +selects the column in the list query instead of deferring it. +Alternative considered: disabling the optimizer's `only` optimisation for the +type. Rejected, because it would widen every query to stop one field's N+1. +(Superseded below: the field is replaced by `original_audio: auto`, which the +optimizer selects on its own because it names the column.) + +**File fields are published by one project-wide `File` type.** +`typelearn.graphql` declares `File { url }` and registers it in +Strawberry-Django's `field_type_map` for `FileField` and `ImageField`. The +map is looked up by exact field class, so each class needs its own entry. +`Exercise.original_audio` is then a plain `auto` field, exposed by the +model's own name. `url` is absolute, built from the request, as the clip URL +requirement asks. +The map is read when a type is defined, so the override must run before any +`strawberry_django.type` is declared. It runs when `typelearn.graphql` is +imported, and `exercises/schema.py` imports that module before defining its +types. +Alternatives considered: +- Keeping `audioUrl` as a computed string. It worked, but it is a related + value flattened into a scalar. +- A one-off `AudioFile` type annotated on the one field. That is the same + shape, but each future file field would have to remember it, and forgetting + would silently publish `DjangoFileType`'s `path`, the file's location on the + server, and its `size`, read from disk per row (`ImageField` adds width and + height, read by opening the image). The project-wide override makes the safe + type the default. + +**`TranslationOrigin` is `strawberry.enum(models.Translation.Origin, +name='TranslationOrigin')`, and `origin` becomes a plain annotated field.** +Strawberry publishes a `TextChoices` class by member name and serialises the +raw column value (`'machine'`) to `MACHINE`. That was checked on a scratch +schema before choosing this. Alternative considered: annotating `origin` as +`auto` with `GENERATE_ENUMS_FROM_CHOICES`. Rejected, because the generated +enum is named after the app and model, which would rename the published type. + +**`proposeTranslation` validates with `full_clean()` and translates Django's +error codes.** The mutation builds the unsaved `Translation` and calls +`full_clean()`. Each resulting error is mapped onto the API's existing +refusals: + +| Django | field | API code | +| --- | --- | --- | +| `blank` | `text` | `BLANK` | +| `max_length` | `text` | `TOO_LONG` | +| `unique_together` | `__all__` (`unique_translation_text`) | `DUPLICATE` | +| anything else | — | `INVALID` | + +The API's English messages stay as they are, so the visible behaviour does +not change. `INVALID` should never be reached, because every other field is +filled in by the server. It exists so that an unforeseen validation failure +still has a code rather than becoming an uncoded error. Validation runs where +the blank and length checks ran before, ahead of the rate limit. This moves +the duplicate check ahead of the rate limit too, which costs one indexed query +on a request that would otherwise be refused. +Alternative considered: keeping the hand-written checks and only reading the +length from the model. Rejected, because it leaves the duplicate check as a +second copy of `unique_translation_text`. + +The `IntegrityError` fallback and its savepoint stay. `full_clean()` checks +uniqueness before the insert, so a concurrent identical suggestion can still +win the race. The savepoint is there so that catching the error leaves any +surrounding transaction usable, not for atomicity. + +**The column is the maximum.** `Translation.TEXT_MAX_LENGTH = 500` is +declared on the model, and `text` is declared with `max_length` set to it, so +no migration results. The provider's `validated()` and `load_translations` +read the constant. The `TRANSLATION_MAX_LENGTH` setting is deleted. Tests that +need a small limit patch the constant, which both readers look up at call +time. + +**`exercises` uses the library's own pagination, on a plain list.** It +becomes `strawberry_django.field(pagination=True)` with no resolver. The +library then publishes `filters`, `ordering` and `pagination` itself, and +applies them in that order. Applying them in that order is the only reason +`LimitedField` and the resolver existed, so both are deleted. Without the +argument nothing is sliced, so the frontend's `Translations` query (200 ids, +no pagination) is unaffected. With it, the library defaults `limit` to +`PAGINATION_DEFAULT_LIMIT` and caps it at `PAGINATION_MAX_LIMIT`, both 100. +The cap is declared in `settings.py` as +`STRAWBERRY_DJANGO['PAGINATION_MAX_LIMIT']`, read from +`STRAWBERRY_PAGINATION_MAX_LIMIT` like the other query limits, so it is a +visible, configurable part of the contract rather than a dependency's +default. +Alternatives considered: `offset_paginated`, which returns +`{ results totalCount pageInfo }`. Rejected, because it changes the response +shape the frontend reads and nothing needs a total count. Keeping `limit` as an +alias was also rejected: no client sends it, and two ways to page is the +duplication this change removes. + +**Mutations take model inputs through `data`, under a `translation` +namespace.** The two inputs are declared with `@strawberry_django.input`: + +- `TranslationCreateInput`, over `Translation`, with `exercise`, `language`, + `text` and `client_id` as `auto`. The foreign key becomes the standard + `OneToManyInput { set: ID }`. `clientId` is published nullable because the + column is `blank=True` (machine rows have none). The mutation's UUID check + refuses a missing value as `INVALID_CLIENT_ID`, as it refuses a malformed + one. +- `TranslationRatingInput`, over `TranslationRating`, with `translation` and + `client_id` as `auto`. `value` is annotated `TranslationRatingValue`, + because `NONE` (withdraw) is part of the deployed wire format and is no + value a stored rating holds, and `auto` would publish a bare `Int`. + +Both mutations live on `TranslationMutations`, which the root `Mutation` +exposes as `translation`. The namespace's wrapper field carries no permission +extension, because the API has no authentication, so the field exists only as +the namespace. No `name=` is given, so the exposed names stay `rateTranslation` +and `proposeTranslation`. +Deliberately not followed literally: the conventions name a create's method +`create_`. The point of that rule is to avoid a bare +`create`/`update`, whose generated union names would collide. +`propose_translation` already avoids that, and "propose" carries meaning that +"create" loses: the row waits for a moderator. `rate_translation` creates, +changes or deletes a rating, so it is no create at all. + +**One applier sets an input onto a row: `typelearn.graphql.update_model_instance_based_on_input(data, instance, field_errors)`.** +It lives in the project package, the equivalent of the conventions' +`config.graphql`. For each field the input actually carries, it does one of +three things: + +- **A `{ set: id }` relation:** resolves the related row and assigns it. An id + no row has, a malformed id, or `set: null` adds `Not found.` to + `field_errors[]`. Every relation an input carries here is required, + so clearing one is not supported; it is added when a nullable relation is + first exposed. +- **An enum:** assigns the member's `.value`. +- **Anything else:** assigns it as given. + +`UNSET` fields and many-to-many relations are skipped. The convention's +signature takes a `user`; this API has none, so the parameter is left out. +`field_errors` holds `ValidationError`s rather than strings, so each keeps a +code (`not_found`) and goes through the same refusal table as `full_clean()`'s +errors. A resolver therefore does three things: build the row with only what +the input cannot carry (`origin`, `status`, `address_hash`), apply the input, +and refuse on `field_errors` before validating. `object_or_fail` is deleted. + +**An unpublished translation is refused as if it did not exist.** A +translation is visible only once published. After the applier, the rating +resolver locks the translation and checks it. If the translation is missing or +not published, the resolver assigns rather than appends +`field_errors['translation']`, replacing any `Not found.` the applier left. The +two cases then produce one response, with the same code (`NOT_FOUND`) and the +same message (no id is interpolated into it). A parametrized test asserts the +literal response for a pending row, a rejected row, an id no row has, and a +malformed id. This supersedes the `NOT_PUBLISHED` code that +`add-phrase-translations`' design lists. Its design and tasks are left as the +record of what was built then; its spec delta carries the new behaviour. + +**`proposeTranslation` returns the stored row**, cast to `TranslationType`. +It is the caller's own text, so returning it reveals nothing. The suggestion +stays out of `Exercise.translation`, which shows published rows only. +`ProposeTranslationResult` is deleted. The frontend never read `accepted`, so +it only changes which field it selects. + +**`deck` refuses through `fail('INVALID_SIZE', …)`,** the module's existing +helper, keeping the message that names the argument. + +**Mutations are atomic by decorator.** `rate_translation` was already almost +entirely inside a `with transaction.atomic()`. It becomes `@transaction.atomic` +under the mutation decorator, and both mutations use +`@strawberry_django.mutation`. That decorator applies no error handling here +because the default is off. `refresh_from_db()` after the `F()` update stays, +since the counts really did change, but is narrowed to the two count fields. + +**Tests follow the test conventions.** In each module, GraphQL documents are +constants at the top, with arguments passed as variables rather than +formatted into the string. They are followed by helpers, then flat test +functions marked `@pytest.mark.django_db()`. Every response is assigned and +inspected, including setup calls, which now assert the state they produce. +Error assertions check `len(body['errors']) == 1` before reading the code. +Literals shared between setup and assertion become named locals. + +## Risks / Trade-offs + +- [The duplicate check now runs before the rate limit] → A rate-limited + client sending a duplicate is told `DUPLICATE` instead of `RATE_LIMITED`. + Both refuse and store nothing. The order of two refusals is not part of + the contract. +- [`full_clean()` validates fields the old code did not, such as `client_id` + and `address_hash` length and the `origin`/`status` choices] → All of them + are set by the server from already-validated input. An unforeseen failure + surfaces as `INVALID`, not as a stored bad row. +- [A deployment that set `TRANSLATION_MAX_LENGTH`] → None of the chart, + `.env.example` or the docs ever set it, and any value above 500 already + failed at the database. A lower value stops having an effect, which the + proposal states. + +- [`exercises(limit:)` stops validating] → The only callers are the spec's + examples and hand-run GraphiQL queries. The frontend never sends it, and + `src/frontend` was searched to confirm this. + +- [The mutations' shape changes] → The frontend changes in the same commit. + The two images roll out separately, though, so a tab already open on the old + bundle gets an error on rate and propose until it reloads. Reading still + works. The window is one rollout, and a failed rating or suggestion shows the + generic message rather than losing anything silently. + +## Migration Plan + +No data or database migration. Deploy the backend and the frontend together. +Rolling back is deploying both previous images. A backend rolled back alone +breaks rate and propose for the new bundle, so roll back as a pair. diff --git a/openspec/changes/follow-graphql-conventions/proposal.md b/openspec/changes/follow-graphql-conventions/proposal.md new file mode 100644 index 0000000..82e6840 --- /dev/null +++ b/openspec/changes/follow-graphql-conventions/proposal.md @@ -0,0 +1,127 @@ +## Why + +An audit of `exercises/schema.py` against the project's Strawberry-Django +conventions found one real defect and a handful of places where the code +works but has to be kept in agreement with something else by hand. + +The defect: `audioUrl` is a plain `@strawberry.field`, so the optimizer does +not know it reads `original_audio` and leaves the column out of its `.only()`. +Django then fetches the deferred column once per row. The frontend's `Deck` +query selects `audioUrl` for every exercise in a 200-exercise deck, so one page +load costs about 201 queries where it should cost one. + +The rest are duplications that can drift: + +- `TranslationOrigin` is a hand-written copy of `Translation.Origin`, converted + by a resolver. A new origin added to the model would be missing from the API + until someone noticed. +- A translation's maximum length lives in two places: the column's + `max_length=500` and the `TRANSLATION_MAX_LENGTH` setting, which only works + while the two agree. Raising the setting turns a coded `TOO_LONG` refusal + into a database error. The proposal mutation also re-implements the blank + and duplicate checks that the model already declares. +- `deck` refuses a size below one with a bare `GraphQLError`, the only refusal + in the schema without the stable code the frontend maps to a message. + +The GraphQL tests also stray from the conventions: documents are written +inline or mid-file, setup calls discard their responses, and error assertions +read `errors[0]` without first asserting how many errors there are. + +## What Changes + +- `audioUrl` declares the column it reads, so listing exercises costs one + query however many rows come back. +- `TranslationOrigin` is generated from `Translation.Origin`. The published + enum is unchanged: `MACHINE`, `LEARNER`. +- `proposeTranslation` validates the new row with the model's own + `full_clean()` and maps Django's validation codes onto the API's existing + `BLANK`, `TOO_LONG` and `DUPLICATE` codes. The codes clients see do not + change. +- The `TRANSLATION_MAX_LENGTH` setting is removed. The `text` column's + `max_length` is the only maximum, and the machine-translation provider and + `load_translations` read it from the model. No chart, environment file, or + document sets the variable, so no deployment is affected. +- `deck(size:)` below one is refused with the code `INVALID_SIZE`. +- **BREAKING** `exercises(limit: Int)` becomes Strawberry-Django's standard + `exercises(pagination: { offset, limit })`. The hand-written field class and + resolver that existed only to apply `limit` after `filters` and `ordering` + are deleted, because the library already applies pagination in that order. + Clients gain `offset`, and a page is capped at 100 exercises by default, + configurable through `STRAWBERRY_PAGINATION_MAX_LIMIT`. Leaving `pagination` + out still returns the whole (filtered) list. No client sends `limit`: the + frontend's only `exercises` query passes `filters` alone, so the rename + breaks nothing that ships. +- **BREAKING** The two mutations take Strawberry-Django model inputs through + a `data` argument, nested under a `translation` namespace: + `translation { rateTranslation(data: TranslationRatingInput!) }` and + `translation { proposeTranslation(data: TranslationCreateInput!) }`. Related + rows are named with the standard relation input (`exercise: { set: ID }`, + `translation: { set: ID }`), and the input is applied to the row by one + shared applier rather than copied field by field. +- **BREAKING** `proposeTranslation` returns the stored `Translation` instead + of `ProposeTranslationResult { accepted }`. +- Rating a pending or rejected translation is refused exactly like an id no + translation has (`NOT_FOUND`, the same message), instead of `NOT_PUBLISHED`, + so the response no longer reveals that a hidden suggestion exists. The + frontend shows its generic message for both codes, so learners see no + change. +- **BREAKING** `Exercise.audioUrl` becomes `Exercise.originalAudio { url }`: + the model's file field is exposed by its own name instead of through a + computed string. Every file field in the project is published as one `File` + type, with only an absolute `url`, by overriding Strawberry-Django's type + for `FileField` and `ImageField` once. Its default `DjangoFileType` would + also publish the file's path on the server, and read each file's size from + disk for every row. +- The GraphQL test modules are restructured to the test conventions: document + constants at the top, every response inspected, error counts asserted. + +## Capabilities + +### New Capabilities +None. + +### Modified Capabilities +- `exercise-api`: a deck size below one is refused with a stable error code, + listing exercises costs a bounded number of database queries, the + catalog is paged with `pagination: { offset, limit }` instead of `limit`, + and a clip is published as `originalAudio { url }` instead of `audioUrl` + (the requirement "audioUrl is directly playable" is renamed "The clip URL + is directly playable"). +- `typing-practice`: the audio playback scenarios name `originalAudio.url`. + +The mutations are specified by the still-open `add-phrase-translations` +change ("Anonymous translation mutations"), which is not in `openspec/specs/` +yet, so they cannot be modified from here. That change's own delta is amended +instead: the over-long scenario's "configured maximum", the mutations' schema +block, the unpublished-rating scenario, and the proposal's return value. + +## Impact + +- `src/backend/exercises/schema.py`: the `audio_url` hint, the generated + enum, model validation in `propose_translation`, and the `deck` error code. +- `src/backend/typelearn/settings.py`: `TRANSLATION_MAX_LENGTH` removed; + `STRAWBERRY_DJANGO['PAGINATION_MAX_LIMIT']` declared from + `STRAWBERRY_PAGINATION_MAX_LIMIT`. +- `src/backend/exercises/schema.py`: `LimitedField` and the `exercises` + resolver removed in favour of `strawberry_django.field(pagination=True)`. +- `docs/deployment.md`: the page cap, next to the deck cap. +- `src/backend/exercises/translation/providers.py`, + `src/backend/exercises/management/commands/load_translations.py`: read the + maximum from the model. +- `src/backend/exercises/tests/`: `test_schema.py` and + `test_translation_schema.py` restructured, new tests for each fix, and the + two tests that lowered `TRANSLATION_MAX_LENGTH` rewritten. +- `src/backend/typelearn/graphql.py` (new): the shared input applier, and the + project-wide `File` type for file fields. +- `openspec/changes/add-phrase-translations/specs/exercise-api/spec.md`: + the amendments listed above. +- Published schema: `exercises` takes `pagination: OffsetPaginationInput` + instead of `limit: Int`; the mutations change shape as above. +- Frontend: `src/frontend/src/stores/exercise.js` asks for + `originalAudio { url }` and `SentenceView.vue` plays it; + `src/frontend/src/stores/translation.js` sends the new mutation + documents. Its unit tests (`stores/__tests__/translation.test.js`, + `components/__tests__/TranslationPanel.test.js`) and the e2e stub + (`e2e/fixtures.js`, `e2e/translation.spec.js`) follow. Frontend and backend + images roll out separately, so a tab still running the old bundle gets an + error on rate and propose until it reloads. diff --git a/openspec/changes/follow-graphql-conventions/specs/exercise-api/spec.md b/openspec/changes/follow-graphql-conventions/specs/exercise-api/spec.md new file mode 100644 index 0000000..8a5eafc --- /dev/null +++ b/openspec/changes/follow-graphql-conventions/specs/exercise-api/spec.md @@ -0,0 +1,148 @@ +## RENAMED Requirements + +- FROM: `### Requirement: audioUrl is directly playable` +- TO: `### Requirement: The clip URL is directly playable` + +## MODIFIED Requirements + +### Requirement: Single exercise lookup +The schema SHALL expose an `exercise(id: ID!)` query returning the matching exercise, or null when no exercise has that id. + +#### Scenario: Existing id +- **WHEN** a client queries `exercise` with the id of an ingested exercise +- **THEN** that exercise is returned with its `sentence` and `originalAudio.url` populated + +#### Scenario: Unknown id +- **WHEN** a client queries `exercise` with an id no row has +- **THEN** the response contains `null` for the field rather than an error + +### Requirement: GraphiQL is available in development +The `/graphql/` endpoint SHALL serve the GraphiQL explorer when `DEBUG` is on, so the schema can be exercised by hand without a frontend. + +#### Scenario: Opening the endpoint in a browser +- **WHEN** a developer opens `/graphql/` in a browser with `DEBUG` on +- **THEN** the GraphiQL interface loads and can run `{ exercises(pagination: {limit: 1}) { sentence originalAudio { url } } }` against the live database + +### Requirement: Schema field names are camelCase +Strawberry converts Python `snake_case` field names to `camelCase` in the GraphQL schema by default. The published contract SHALL be written in the casing the schema actually emits, so documented queries execute as written. + +Schema: +```graphql +type File { + url: String! +} + +type Exercise { + id: ID! + sentence: String! + sentenceId: String + upVotes: Int! + difficulty: Int! + originalAudio: File! +} + +input OffsetPaginationInput { + offset: Int! = 0 + limit: Int +} + +type Query { + exercises(filters: ExerciseFilter, ordering: [ExerciseOrder!]! = [], pagination: OffsetPaginationInput): [Exercise!]! + exercise(id: ID!): Exercise + deck(size: Int!): [Exercise!]! +} +``` + +#### Scenario: Documented query executes +- **WHEN** a client sends `{ exercises { id sentence originalAudio { url } difficulty } }` +- **THEN** the query validates and returns data, with no "Cannot query field" error + +#### Scenario: snake_case field names are not part of the contract +- **WHEN** a client sends `{ exercises { sentence up_votes } }` +- **THEN** the query fails validation, because the schema exposes `upVotes` + +### Requirement: Exercises query returns the MVP catalog +The `exercises` query SHALL return the ingested exercises from the database. A client MAY page through them with `pagination: { offset, limit }`, which is applied after any `filters` and `ordering`. Without `pagination` the whole filtered list is returned. A page SHALL hold at most the server's maximum page size, however large a `limit` the client asks for. + +#### Scenario: Returning the full catalog +- **WHEN** a client queries `exercises` with no arguments against a database holding the 100 MVP exercises +- **THEN** 100 items are returned + +#### Scenario: Limiting results +- **WHEN** a client queries `{ exercises(pagination: {limit: 1}) { id sentence originalAudio { url } } }` +- **THEN** exactly one exercise is returned with all three fields populated + +#### Scenario: Paging with an offset +- **WHEN** a client queries `exercises(pagination: {offset: 1, limit: 1})` +- **THEN** the second exercise of the unpaged list is returned, alone + +#### Scenario: Paging after filtering and ordering +- **WHEN** a client passes `filters`, `ordering` and `pagination` together +- **THEN** the page is cut from the filtered, ordered list, and no error is returned + +#### Scenario: The page size is capped +- **WHEN** a client asks for a `limit` larger than the server's maximum page size +- **THEN** at most the maximum number of exercises is returned + +#### Scenario: Empty catalog +- **WHEN** a client queries `exercises` before ingestion has run +- **THEN** an empty list is returned rather than an error + +### Requirement: The clip URL is directly playable +An exercise's clip SHALL be published as `originalAudio { url }`, and `url` SHALL be an absolute URL — scheme, host, port, and media path — that an HTML5 `