Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,21 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

# Generated operation types are committed, so a checkout builds without
# running the generator. That only holds if the committed output matches
# the schema and the operations, so regenerate and fail on any diff. This
# runs before typecheck on purpose: a stale tree should fail with "run
# pnpm codegen", not with a type error in generated code.
- name: Check generated GraphQL types are up to date
run: |
set -euo pipefail
pnpm codegen
if [ -n "$(git status --porcelain -- client/src/generated)" ]; then
echo "::error::client/src/generated is stale. Run 'pnpm codegen' and commit the result."
git --no-pager diff -- client/src/generated | head -60
exit 1
fi
- name: Check formatting
run: pnpm format:check

Expand Down
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ coverage
node_modules
pnpm-lock.yaml
*.min.js
client/src/generated
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ CI also builds the project and checks the client bundle size.
| `pnpm format` / `pnpm format:check` | Format or check formatting |
| `pnpm typecheck` | Run TypeScript checks |
| `pnpm test` | Run unit tests |
| `pnpm codegen` | Regenerate GraphQL types after editing an operation |
| `pnpm --filter @t19/server dev` | Start the GraphQL API |
| `pnpm --filter @t19/server db:migrate` | Apply database migrations |
| `pnpm --filter @t19/server db:seed` | Reset to the sample catalog |
Expand All @@ -86,6 +87,23 @@ CI also builds the project and checks the client bundle size.
Tests cover frontend components and backend logic. Additional PostgreSQL tests cover search, sorting, rating updates and filters. These run when `TEST_DATABASE_URL` is configured and require a separate test database with migrations applied. The
[VM setup guide](docs/VM-setup.md) includes API smoke tests.

### GraphQL types

The client's operation types are generated from the repository's `schema.graphql`
with [GraphQL Code Generator](https://the-guild.dev/graphql/codegen), using the
client preset. There are no hand-written result types: `client/src/api/*.ts`
declares each operation with `graphql()` and derives its types from the generated
output, so a field renamed in the contract breaks `pnpm typecheck` instead of
returning `undefined` at runtime.

The generated files are committed under `client/src/generated/`, so a fresh
checkout builds without running the generator. After changing an operation or the
schema, run `pnpm codegen` and commit the result. `pnpm build` runs the generator
first, so a schema change the operations no longer match fails the build, and CI
regenerates and fails on any difference. Generated files are excluded from ESLint
and Prettier: their formatting is the generator's, and a formatting pass would
only create noise in the stale-file check.

## Database seed data

`db:seed` loads 16 curated albums for development. For scalability testing, `db:seed:large` generates 100,000 deterministic synthetic albums in batches of 1,000. You can change the size with `ALBUM_COUNT`:
Expand Down Expand Up @@ -243,9 +261,9 @@ The two `AlbumDetailPage` rows represent the separate chunks for the album detai

**Album details and reviews:** Album pages display tracks, average ratings and reviews. Users can write, edit and delete their own reviews.

**Personal crate:** Users can add or remove albums from their crate on both search results and album detail pages. User identity is simulated using a locally stored ID rather than a login system.
**Personal crate:** Users can add or remove albums from their crate on both search results and album detail pages, and a crate page lists everything they have saved, newest first, with paging. The API returns only the requesting user's items. User identity is simulated using a locally stored ID rather than a login system.

**Planned for M4:** A page for viewing all albums saved in the user's crate, the supporting `myCrate` query, and Playwright end-to-end tests. Browsing albums without entering a search term is also planned.
**Planned for M4:** Playwright end-to-end tests, and browsing albums without entering a search term.

### Backend search behavior

Expand Down
40 changes: 40 additions & 0 deletions client/codegen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { CodegenConfig } from '@graphql-codegen/cli';

/**
* Types for every GraphQL operation come from the schema the server actually
* serves, not from interfaces written by hand next to each operation.
*
* The schema is the repository's root `schema.graphql`: the same file the
* server reads at startup and the client tests validate against. A field that
* is renamed in the contract therefore breaks `pnpm typecheck` instead of
* failing at runtime. We shipped one schema-shape bug this way before (#30).
*
* The documents come from the operation files themselves (`src/api/*.ts`), so
* an operation and its generated types live in the same place. Generated output
* is committed under `src/generated/`, and CI runs this command and fails if
* the committed output is stale - see `.github/workflows/ci.yml`.
*/
const config: CodegenConfig = {
schema: '../schema.graphql',
// Test files are excluded on purpose: a fixture operation would otherwise end
// up in the generated document map and in the production bundle.
documents: [
'src/**/*.{ts,tsx}',
'!src/**/*.test.{ts,tsx}',
'!src/generated/**',
'!src/test/**',
],
// The rest of the client imports with an explicit `.js` extension.
importExtension: '.js',
generates: {
'./src/generated/': {
preset: 'client',
config: {
// The schema's DateTime scalar is an ISO string over the wire.
scalars: { DateTime: 'string' },
},
},
},
};

export default config;
8 changes: 6 additions & 2 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build": "pnpm codegen && tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --noEmit",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"codegen": "graphql-codegen --config codegen.ts"
},
"dependencies": {
"@apollo/client": "^4.3.3",
Expand All @@ -19,6 +20,9 @@
"rxjs": "^7.8.2"
},
"devDependencies": {
"@graphql-codegen/cli": "^7.4.6",
"@graphql-codegen/client-preset": "^6.2.3",
"@graphql-typed-document-node/core": "^3.2.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
Expand Down
17 changes: 16 additions & 1 deletion client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export default function App() {
: window.location.pathname;
const detailMatch = /^\/album\/(\d+)$/.exec(pathname);
const detailId = detailMatch?.[1];
const isCrate = pathname === '/crate';

return (
<>
Expand All @@ -26,9 +27,12 @@ export default function App() {
<a className="brand" href={discoveryHref}>
Crate<span aria-hidden="true">●</span>
</a>
<a href={discoveryHref} aria-current={detailId ? undefined : 'page'}>
<a href={discoveryHref} aria-current={detailId || isCrate ? undefined : 'page'}>
Discover
</a>
<a href={`${basePath}/crate`} aria-current={isCrate ? 'page' : undefined}>
Your crate
</a>
</nav>
</header>
{detailId ? (
Expand All @@ -41,6 +45,16 @@ export default function App() {
>
<AlbumDetailPage id={detailId} />
</Suspense>
) : isCrate ? (
<Suspense
fallback={
<main id="main" tabIndex={-1}>
<p role="status">Loading your crate…</p>
</main>
}
>
<CratePage />
</Suspense>
) : (
<DiscoveryPage />
)}
Expand Down Expand Up @@ -99,3 +113,4 @@ function DiscoveryPage() {
}

const AlbumDetailPage = lazy(() => import('./pages/AlbumDetailPage.js'));
const CratePage = lazy(() => import('./pages/CratePage.js'));
51 changes: 9 additions & 42 deletions client/src/api/albumDetail.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,13 @@
import { gql } from '@apollo/client';
import type { TypedDocumentNode } from '@apollo/client';
import { graphql } from '../generated/gql.js';
import type {
AlbumDetailQuery,
AlbumDetailQueryVariables,
} from '../generated/graphql.js';

export type AlbumDetail = {
id: string;
title: string;
releaseYear: number | null;
artists: { id: string; name: string }[];
genres: { id: string; name: string }[];
tracks: {
id: string;
title: string;
trackNumber: number;
durationSeconds: number | null;
}[];
averageRating: number | null;
reviewCount: number;
isInCrate: boolean;
reviews: {
edges: {
cursor: string;
node: {
id: string;
rating: number;
comment: string;
createdAt: string;
user: { id: string; displayName: string };
};
}[];
pageInfo: { hasNextPage: boolean; endCursor: string | null };
totalCount: number;
};
myReview: { id: string; rating: number; comment: string } | null;
};
export type AlbumDetail = NonNullable<AlbumDetailQuery['album']>;
export type AlbumDetailVariables = AlbumDetailQueryVariables;

type AlbumDetailData = { album: AlbumDetail | null };
type AlbumDetailVariables = {
id: string;
reviewFirst: number;
reviewAfter?: string | null;
};

export const ALBUM_DETAIL: TypedDocumentNode<AlbumDetailData, AlbumDetailVariables> = gql`
export const ALBUM_DETAIL = graphql(`
query AlbumDetail($id: ID!, $reviewFirst: Int!, $reviewAfter: String) {
album(id: $id) {
id
Expand Down Expand Up @@ -90,4 +57,4 @@ export const ALBUM_DETAIL: TypedDocumentNode<AlbumDetailData, AlbumDetailVariabl
}
}
}
`;
`);
41 changes: 41 additions & 0 deletions client/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ type CachedConnection = Omit<AlbumConnection, 'edges'> & {
edges: { cursor: string; node: Reference | StoreObject }[];
};

// The crate is one list per user, so no arguments belong in its cache key; only
// the paging argument decides whether this is another page or a refresh.
type CachedCrate = {
pageInfo: { hasNextPage: boolean; endCursor: string | null };
edges: { cursor: string; node: Reference | StoreObject }[];
totalCount: number;
};

// InMemoryCache stores server results and shares album records by their IDs.
export const cache = new InMemoryCache({
typePolicies: {
Expand Down Expand Up @@ -49,6 +57,39 @@ export const cache = new InMemoryCache({
return { ...incoming, edges };
},
},

myCrate: {
// One crate per user, so there is nothing to key on: `after` is paging,
// not a different list. The user id never reaches the cache key.
keyArgs: false,

merge(
existing: CachedCrate | undefined,
incoming: CachedCrate,
{ args, readField },
): CachedCrate {
// A reload of the first page replaces whatever was accumulated.
if (!args?.after) return incoming;
if (!existing || args.after !== existing.pageInfo.endCursor) {
return existing ?? incoming;
}

const ids = new Set(
existing.edges.map((edge) => readField<string>('id', edge.node)),
);
const edges = [...existing.edges];

for (const edge of incoming.edges) {
const id = readField<string>('id', edge.node);
if (!ids.has(id)) {
ids.add(id);
edges.push(edge);
}
}

return { ...incoming, edges };
},
},
},
},
},
Expand Down
81 changes: 59 additions & 22 deletions client/src/api/crate.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import { gql } from '@apollo/client';
import type { TypedDocumentNode } from '@apollo/client';
import { graphql } from '../generated/gql.js';
import type {
AddToCrateMutation,
AddToCrateMutationVariables,
MyCrateQuery,
RemoveFromCrateMutation,
} from '../generated/graphql.js';

export type AddToCrateData = {
addToCrate: { item: { id: string }; album: { id: string; isInCrate: boolean } };
};
export type RemoveFromCrateData = {
removeFromCrate: {
removedAlbumId: string;
album: { id: string; isInCrate: boolean };
};
};
export type CrateVariables = { albumId: string };
export type AddToCrateData = AddToCrateMutation;
export type RemoveFromCrateData = RemoveFromCrateMutation;
export type CrateVariables = AddToCrateMutationVariables;

export const ADD_TO_CRATE: TypedDocumentNode<AddToCrateData, CrateVariables> = gql`
export type CratePage = MyCrateQuery['myCrate'];
export type CrateItemSummary = CratePage['edges'][number]['node'];

export const ADD_TO_CRATE = graphql(`
mutation AddToCrate($albumId: ID!) {
addToCrate(albumId: $albumId) {
item {
Expand All @@ -24,17 +25,53 @@ export const ADD_TO_CRATE: TypedDocumentNode<AddToCrateData, CrateVariables> = g
}
}
}
`;
`);

export const REMOVE_FROM_CRATE = graphql(`
mutation RemoveFromCrate($albumId: ID!) {
removeFromCrate(albumId: $albumId) {
removedAlbumId
album {
id
isInCrate
}
}
}
`);

export const REMOVE_FROM_CRATE: TypedDocumentNode<RemoveFromCrateData, CrateVariables> =
gql`
mutation RemoveFromCrate($albumId: ID!) {
removeFromCrate(albumId: $albumId) {
removedAlbumId
album {
// The page shows enough of each album to reuse the search result card, and
// orders by when it was saved, which is the index the table was designed with.
export const MY_CRATE = graphql(`
query MyCrate($first: Int!, $after: String) {
myCrate(first: $first, after: $after) {
edges {
cursor
node {
id
isInCrate
addedAt
album {
id
title
releaseYear
averageRating
reviewCount
isInCrate
artists {
id
name
}
genres {
id
name
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
`;
}
`);
Loading