Server-Side Applications & Databases

Marketplace Project: Building the Auction API


Learning Objectives

  • You can extend an existing schema while preserving its records and relationships.
  • You can separate HTTP handling, application rules, and database operations.
  • You can coordinate bids and auction closing using transactions, locks, and an explicit clock.
  • You can test rejected, concurrent, and repeated operations at the appropriate boundary.

We have practiced request validation, database access, service boundaries, and state changes under concurrency. Now apply those ideas to the marketplace’s server. Continue in your local marketplace repository (marketplace/) after milestone 12; this part adds milestones 13-18 while preserving the browsing interface from Part 2.

Each card contains the baseline, requirements, checks, and submission instructions for one increment. Continue on master and use the marketplace’s separate test Compose project. Its migrations and fixtures belong to this repository; the practice application’s tables remain separate.

Auction data and read API

Extend the existing schema while preserving its records, then expose the new auction fields through the listing reads. Keep the existing application entry point and move the listing responsibilities into route and repository modules. This produces a working read API before introducing mutations.

Marketplace milestone 13: auction data and read API

0 / 30 points

Continue the marketplace project

Continue in marketplace/ after milestone 12. Run git fetch origin and work on master. Comparison branches contain the preceding accepted increment; inspect one only when you need a baseline. Keep all existing migrations and features.

The supplied baseline is a Deno/Hono API, Svelte client, PostgreSQL database, and dbmate migrations in this Git repository. Existing migrations define users (id, email, display_name, deleted_at), categories (id, name, slug, parent_id), and listings (id, seller_id, category_id, title, description, starting_price). IDs are INTEGERs; seller/category references already exist. Read the supplied migration files for their full constraints; add a new migration rather than modifying them. dbmate migration files use -- migrate:up before forward SQL and -- migrate:down before reversal SQL. Compose supplies the database URL to the migration and API services. Docker with the Compose plugin and Git are the local prerequisites.

Prepare dependencies

In api/deno.json, replace the Hono mappings and add these pinned dependencies:

{
"hono": "npm:hono@4.13.5",
"hono/": "npm:/hono@4.13.5/",
"zod": "npm:zod@4.5.4",
"@hono/standard-validator": "npm:@hono/standard-validator@0.4.0"
}

Remove the old exact hono/cors alias; preserve other imports and tasks. Update this repository’s lockfile using its existing API image. On Linux, macOS, or WSL:

Terminal window
docker compose run --rm --no-deps --user "$(id -u):$(id -g)" -e DENO_DIR=/tmp/deno-cache --volume ./api:/app api deno install

On native PowerShell, replace --user "$(id -u):$(id -g)" with --user 0:0. Keep the resulting api/deno.lock with the declarations and rebuild. Do not copy another application’s lockfile. The Dockerfile retains deno install --frozen.

Schema contract

Add database-migrations/004_auctions.sql, or the next unused number. Store prices as INTEGER euro cents. Preserve todos, users, categories, listings, and existing foreign keys. Extend listings with:

ColumnType and nullabilityDefault / existing records
current_priceINTEGER NOT NULLBackfill from starting_price; future inserts supply it
statusTEXT NOT NULLDefault and backfill draft
versionINTEGER NOT NULLDefault and backfill 1
ends_atTIMESTAMPTZ(3), nullableNull for existing drafts
updated_atTIMESTAMPTZ(3) NOT NULLDefault CURRENT_TIMESTAMP
winning_bid_idINTEGER, nullableNull

Create bids with generated identity INTEGER primary key id, non-null INTEGER listing_id referencing listings and bidder_id referencing users, non-null positive INTEGER amount, and non-null TIMESTAMPTZ(3) created_at defaulting to CURRENT_TIMESTAMP. Restrict deletion of referenced listings and bidders.

Enforce status in draft/active/closed, positive version, current_price at least starting_price, and a non-null deadline for active/closed listings. The nullable winning bid must belong to the same listing: constrain the pair, not only the bid ID. Millisecond precision is part of every new timestamp contract.

For an existing table, add a derived column as nullable, fill it from each row’s existing value with UPDATE, then add NOT NULL. A default alone does not express a dependency on another column in the same row.

The winner reference is used when auctions close. Use this supplied SQL after creating bids and adding listings.winning_bid_id. It ensures the winning bid belongs to that listing; you do not need to design a circular constraint:

ALTER TABLE bids ADD CONSTRAINT bids_listing_and_id UNIQUE (listing_id, id);
ALTER TABLE listings ADD CONSTRAINT winner_belongs_to_listing
FOREIGN KEY (id, winning_bid_id) REFERENCES bids(listing_id, id);

Begin the down section by dropping winner_belongs_to_listing and listings.winning_bid_id, then drop bids before removing the other new listing columns. This removes the reference before its target. During normal application use, updated_at records accepted changes, including the final closing result.

Backfill current_price before making it required. Existing rows become drafts; do not invent deadlines. Add a dbmate down section in dependency-safe order. Test up/down/up and preservation of seeded records only in disposable state.

Organize the files

Existing responsibilityDestination
Listing collection and detail queriesapi/src/repositories/listing-repository.ts
Listing routes, including the contributed detail routeapi/src/routes/public-api.ts
Listing ID schemaapi/src/schemas/id-schema.ts

Keep the existing app-run.ts, database.ts, todoRepository.ts, and categoryRepository.ts. Preserve their behavior and the existing listener port. Move only the listing reads and their routes into the modules above, updating their imports. Remove the superseded listingRepository.ts and the two src/marketplace/listing-detail-*.ts files after moving their behavior.

app.ts exports the configured Hono app. Importing it must not listen or query PostgreSQL. Retain the existing health, todo, category, and CORS setup.

Preserve and extend reads

Keep GET /health → {status:"ok"}, GET /api/todos → the existing todo array, GET /api/categories → the existing category array, GET /api/listings, and GET /api/listings/:listingId. Invalid listing IDs return 400 INVALID_ID; syntactically valid missing IDs return 404 NOT_FOUND. Errors use {error:{code,message}}. Use a Zod path schema and sValidator, then the parsed numeric ID. Collection and detail use one representation. Add currentPrice, status, version, endsAt (ISO string or null), and winningBidId (integer or null) to the existing listing fields. Keep the 100-row bound and deterministic order.

Listing representation

A listing JSON object contains numeric id, sellerId, categoryId, startingPrice, currentPrice, and version; strings title, description, sellerName, and categoryName; status (draft, active, or closed); endsAt (ISO timestamp with milliseconds, or null); and winningBidId (integer, or null). Names come from the related user/category records. Prices are integer cents. Collection reads return a bare array, ordered by listing ID ascending and limited to 100; a detail read returns a bare object. Mutation responses use {listing}. Path IDs match [1-9][0-9]* and are at most 2147483647.

Test the preserved application

Group database test cases as awaited steps, clean fixture-owned rows, and close the shared pool once after the group. Retain the existing health import test that needs no network permission. Keep it in api/src/app_test.ts and run docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml run --build --rm --no-deps api deno test --no-check --cached-only --allow-env src/app_test.ts. An accidental query or listener should fail rather than receive extra permission.

Common local test and submission workflow

Use both Compose files and one distinct project name, such as wsd-marketplace-test, for all these milestones. Preserve the override’s networks, ports, and temporary database storage. Commands run from marketplace/.

Terminal window
docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml run --build --rm database-migrations

Inspect dbmate status, the preserved seed rows, and accepted/rejected inserts in that database. Reset this test project when migrations change:

Terminal window
docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml down

Later handouts give focused checks; milestone 18 gives the complete acceptance sequence. Run suites sequentially. Restart an already-running test API after source changes because it does not watch files. Never reset development data to run tests. Do not commit secrets, generated builds, test results, or node_modules. Inspect git diff, stage the changed files, commit, then git push origin master.

After the schema and read-route changes, run the existing read regressions:

Terminal window
docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml run --build --rm api deno test --no-check --cached-only --allow-env --allow-net tests/student_category_test.ts src/app_test.ts

Type checking is optional in this milestone and does not affect points. The grader assesses executable tests and application behavior.

Points

  • 10: Preserve existing records through up/down/up.
  • 10: Enforce auction constraints.
  • 10: Expose auction fields through validated read routes and preserve the application import boundary.

Create and list drafts

Build the first complete mutation path: validate a proposed listing, persist a draft, and return it through the API. Milestone 14 supplies the temporary actor adapter, database clock, and fixture through Git. Use the selected actor to identify the seller and retrieve their own listings.

Marketplace milestone 14: create and list drafts

0 / 25 points

Receive the supporting modules after milestone 13

Continue in your accepted marketplace repository. The contribution supplies the auction fixture, a temporary actor adapter and its schema, and a small database-clock helper. These new files support the lifecycle task; keep their implementations unchanged. With a clean working tree, inspect and receive them:

Terminal window
git fetch origin
git diff --stat HEAD..origin/milestone-14
git diff HEAD..origin/milestone-14
git merge --ff-only origin/milestone-14

If local commits prevent fast-forwarding, inspect the contribution and use git cherry-pick origin/milestone-14; do not reset your work.

withAuctionFixture(options, async (f) => ...) creates users, a category, and a listing in the separate test database. Options are startingPrice, status (draft/active), and endsAt (Date). The callback receives the shared sql client, listingId, sellerId, bidderIds, and categoryId. The helper cleans its rows; the parent test closes sql once in finally after all awaited test steps.

Use the supplied actor adapter

Import developmentActor and DevelopmentEnv from @src/middleware/development-identity.ts. Create the route module with new Hono<DevelopmentEnv>() and register publicApi.use("/api/*", developmentActor) before its routes. The adapter reads X-Development-User-ID, checks that the selected user exists and is not deleted, and stores {id} as currentUser. Use c.get("currentUser").id for the seller. It requires an actor for mutations and GET /api/my/listings, leaving public reads available without one.

A missing or invalid actor receives 401 DEVELOPMENT_IDENTITY_REQUIRED. This is temporary actor selection for the local learning project. Part 5 replaces it with authentication.

Listing representation

A listing JSON object contains numeric id, sellerId, categoryId, startingPrice, currentPrice, and version; strings title, description, sellerName, and categoryName; status (draft, active, or closed); endsAt (ISO timestamp with milliseconds, or null); and winningBidId (integer, or null). Names come from the related user/category records. Prices are integer cents. Collection reads return a bare array, ordered by listing ID ascending and limited to 100; a detail read returns a bare object. Mutation responses use {listing}. Path IDs match [1-9][0-9]* and are at most 2147483647.

Create and read drafts

Add routes in routes/public-api.ts and mount that route module once at / from app.ts. Declare full /api/... paths. Use Zod with sValidator for paths and bodies.

POST /api/listings accepts exactly these required fields: title (trimmed 1-120 characters), description (string up to 4000, including empty), categoryId (positive existing INTEGER), and startingPrice (INTEGER cents, 0-2147483647). Seller comes from context. Return 201 {listing} with currentPrice equal to startingPrice, draft status, version 1, null endsAt and winningBidId, and the normal read fields.

GET /api/my/listings returns a bare array of the actor’s normal listing representations, ordered by ID ascending and capped at 100.

Creation service and errors

Export createDraft(sellerId, input) from services/listing-service.ts. Import the shared sql client, use a transaction, and put category lookup and insertion in draft-repository.ts. Inputs have already passed the request schema. Reject an unknown category with ResourceError(“INVALID_CATEGORY”) from services/marketplace-errors.ts. Return the normal listing representation.

Use strict request schemas. Invalid or unexpected body fields, or unsupported JSON content type, return 400 INVALID_INPUT; malformed JSON returns 400 INVALID_JSON; an unknown category returns 400 INVALID_CATEGORY. Missing or invalid actors return 401 DEVELOPMENT_IDENTITY_REQUIRED. Errors contain {error:{code,message}}; unexpected failures return generic 500 INTERNAL_ERROR. Rejected creation must leave the database unchanged. Keep the existing public reads and their INVALID_ID/NOT_FOUND outcomes.

Write api/tests/student_draft_creation_test.ts. Test successful creation, invalid input, and owned-listing filtering with two different actors. Verify the stored seller comes from the header and that rejected requests add no rows. Group database checks in awaited t.step calls, and close sql once in finally.

Use the following malformed-JSON adapter in the route module:

import { HTTPException } from "hono/http-exception";
publicApi.onError((error, c) => error instanceof HTTPException && error.status === 400
? c.json({error: {code: "INVALID_JSON", message: "Malformed JSON"}}, 400)
: c.json({error: {code: "INTERNAL_ERROR", message: "Unexpected error"}}, 500));

Working repository and local checks

This is a cumulative Git exercise. Start from the supplied marketplace repository at the preceding accepted milestone; the existing code and migrations are the baseline, not a separate implementation task. Work on master in marketplace/. Paths such as services/... below are relative to api/src/; @src/ resolves to that directory. The API uses Deno, Hono, Zod, sValidator, and postgres.js through api/deno.json. Keep existing features and tests.

Docker Compose provides database, database-migrations, api, client, and e2e-tests. compose.test.yaml removes exposed API/client ports and uses temporary database storage. Use both files and a distinct project name for every test command; the API receives DATABASE_URL from Compose before imports. From the repository root, first apply migrations:

Terminal window
docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml run --build --rm database-migrations

Run the focused commands in this handout. At the end, stop only this test project with docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml down. Run suites sequentially. Inspect git diff, commit the requested source/tests and any changed lockfile, and git push origin master to submit. Do not commit secrets, dependencies, builds, or test results.

Check and submit

Run the focused check from marketplace/:

Terminal window
docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml run --build --rm api deno test --no-check --cached-only --allow-env --allow-net tests/student_draft_creation_test.ts

Type checking is optional in this milestone and does not affect points. The grader assesses executable tests and application behavior.

Points

  • 10: Create and persist a complete draft.
  • 10: Reject invalid creation without changing stored state.
  • 5: Use the selected seller and filter owned listings.

Edit, publish, and delete drafts

Extend the working creation path with partial edits and version checks. Then make publication an explicit state transition and restrict deletion to drafts. Test the accepted changes and verify that rejected operations preserve state.

Marketplace milestone 15: edit, publish, and delete drafts

0 / 35 points

Continue after milestone 14

Keep draft creation, owned-listing reads, and their tests. The supplied actor adapter and fixture are already installed. Mutations obtain the actor from X-Development-User-ID; the adapter checks an existing, non-deleted user and stores currentUser in Hono context. The fixture creates test-owned users, category and listing, then deletes its rows; the owning test closes the shared sql client.

Import withAuctionFixture from ./helpers/auction-fixture.ts in your test file. Call withAuctionFixture({status: "draft"}, async (f) => { ... }); its options also accept startingPrice and endsAt (Date). The callback receives sql, listingId, sellerId, bidderIds (two users), and categoryId. Await every operation before the callback returns. Group tests as awaited t.step calls inside one Deno.test, and close the shared sql in finally after all steps.

This milestone adds partial editing, publication, and draft deletion. Implement and test editing first, then publication and deletion.

Listing representation

A listing JSON object contains numeric id, sellerId, categoryId, startingPrice, currentPrice, and version; strings title, description, sellerName, and categoryName; status (draft, active, or closed); endsAt (ISO timestamp with milliseconds, or null); and winningBidId (integer, or null). Names come from the related user/category records. Prices are integer cents. Collection reads return a bare array, ordered by listing ID ascending and limited to 100; a detail read returns a bare object. Mutation responses use {listing}. Path IDs match [1-9][0-9]* and are at most 2147483647.

Edit drafts

PATCH /api/listings/:listingId accepts an expected integer version from 1 through 2147483647 and at least one of those four editable fields. Preserve omissions; reject null and unknown fields. Lock, check existence, draft status, then version; validate any category change. A price edit changes currentPrice too. Increment version once, set updatedAt from the post-lock clock, and return 200 {listing}.

Publish and delete

POST /api/listings/:listingId/publish accepts exactly version (integer 1-2147483647) and endsAt, an ISO timestamp with an explicit timezone (Z or a numeric offset). Use this supplied schema in api/src/schemas/listing-schema.ts, alongside its existing z import:

export const PublicationDeadlineSchema = z.string().datetime({ offset: true })
.transform((value) => new Date(value).toISOString());

Use PublicationDeadlineSchema for the endsAt field in the strict publication body schema. It accepts timestamps with seconds, with or without fractional seconds, and normalizes them to a UTC ISO string at JavaScript millisecond precision. For example, 2030-01-01T14:00:00+02:00 becomes 2030-01-01T12:00:00.000Z; a timestamp without a timezone is rejected. Pass the parsed string to the service. The schema handles format and normalization; the service decides whether publication is allowed.

Under the lock, check existence, draft state, and version, then require a deadline strictly later than the service clock. Set active, increment version once, update the timestamp, and return 200 {listing}.

DELETE /api/listings/:listingId takes no body. Lock and require a draft before deleting; return empty 204. Do not cascade auction history.

Errors and service interfaces

Use {error:{code,message}}; wording is not graded. Apply request checks in this order: actor valid (401 DEVELOPMENT_IDENTITY_REQUIRED), path syntax (400 INVALID_ID), then JSON syntax/shape (400 INVALID_JSON/INVALID_INPUT). Missing or unsupported JSON content type gives 400 INVALID_INPUT, as in the chapter exercises. Use sValidator and the supplied malformed-JSON example below; no separate media-type middleware is required. Public reads need no actor. Resource decisions follow: missing listing 404 NOT_FOUND; non-draft 409 NOT_DRAFT; changed version 409 STALE_VERSION; unknown category 400 INVALID_CATEGORY; past/equal publication deadline 400 INVALID_DEADLINE. Unexpected failures return generic 500 INTERNAL_ERROR. Rejection preserves stored state.

Export ResourceError from services/marketplace-errors.ts with those resource codes. The service interfaces in services/listing-service.ts are:

  • editDraft(listingId, input) → listing.
  • publishDraft(listingId, {version, endsAt}, readNow = readDatabaseNow) → listing.
  • deleteDraft(listingId) → no result value.

Services import the shared sql from @src/database.ts, receive validated input, open their transaction, and pass its tx to draft-repository.ts helpers. Read the supplied database clock after locking. For publication boundary tests, readNow is an optional function (tx) => Promise<Date>; for example, async () => new Date("2030-01-01T12:00:00.000Z"). Production callers omit it. Ordinary editing uses the database-clock helper directly for updated_at.

Add tests in api/tests/student_draft_test.ts. Exercise partial edit, stale edit, publication, invalid input, and rejected deletion. Assert stored values remain unchanged after rejection. Use future/past dates for HTTP tests and a supplied time for an exact publication boundary.

Keep the following error adapter in the route module:

import { HTTPException } from "hono/http-exception";
publicApi.onError((error, c) => error instanceof HTTPException && error.status === 400
? c.json({error: {code: "INVALID_JSON", message: "Malformed JSON"}}, 400)
: c.json({error: {code: "INTERNAL_ERROR", message: "Unexpected error"}}, 500));

Working repository and local checks

This is a cumulative Git exercise. Start from the supplied marketplace repository at the preceding accepted milestone; the existing code and migrations are the baseline, not a separate implementation task. Work on master in marketplace/. Paths such as services/... below are relative to api/src/; @src/ resolves to that directory. The API uses Deno, Hono, Zod, sValidator, and postgres.js through api/deno.json. Keep existing features and tests.

Docker Compose provides database, database-migrations, api, client, and e2e-tests. compose.test.yaml removes exposed API/client ports and uses temporary database storage. Use both files and a distinct project name for every test command; the API receives DATABASE_URL from Compose before imports. From the repository root, first apply migrations:

Terminal window
docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml run --build --rm database-migrations

Run the focused commands in this handout. At the end, stop only this test project with docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml down. Run suites sequentially. Inspect git diff, commit the requested source/tests and any changed lockfile, and git push origin master to submit. Do not commit secrets, dependencies, builds, or test results.

Check and submit

Run the focused check from marketplace/:

Terminal window
docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml run --build --rm api deno test --no-check --cached-only --allow-env --allow-net tests/student_draft_test.ts

Type checking is optional in this milestone and does not affect points. The grader assesses executable tests and application behavior.

Points

  • 20: Edit and publish with state, version, and deadline checks.
  • 15: Reject invalid updates and publication, and allow deletion only for drafts.

Bid rules and boundary tests

Decide whether a proposed bid is allowed before connecting that decision to persistence. An explicit time makes the fixed deadline directly testable. Your tests should also detect implementations that accept equal bids or accept a bid exactly at the deadline.

Marketplace milestone 16: bid rules and boundary tests

0 / 20 points

Continue after milestone 15

Implement the bid decision without a database call.

In schemas/bid-schema.ts export BidInputSchema: a strict object containing only amount, an integer from 1 through 2147483647. In services/bid-rules.ts export:

type AuctionState = {
status: "draft" | "active" | "closed";
currentPrice: number;
endsAt: Date | null;
};

Export BidRuleError with code INVALID_AMOUNT, AUCTION_CLOSED, BID_TOO_LOW, or NOT_FOUND. Its message is the code. NOT_FOUND is reserved for the later persistence operation; evaluateBid itself receives an existing state.

evaluateBid(auction, amount, now) returns {currentPrice, endsAt: Date} without mutating any input. Apply these checks in order:

  1. Validate {amount} with BidInputSchema.safeParse; reject INVALID_AMOUNT.
  2. Require active state, a deadline, and now strictly before it; otherwise AUCTION_CLOSED.
  3. Require amount strictly greater than currentPrice; otherwise BID_TOO_LOW.

Set currentPrice to amount and preserve the publication deadline. Bidding never extends the auction. At or after that deadline, reject the bid. An unchanged Date may be returned directly or copied; object identity is not assessed. Inputs contain valid Date objects and a valid stored price.

Write tests in api/tests/bid-rules_test.ts for invalid amounts, non-active state, equal/low bids, and just before/at/after the deadline. Verify an accepted bid preserves the deadline and does not mutate its inputs. This function needs no database clock, database connection, or listener.

Verify the boundary tests

The grader runs your bid-rules_test.ts against a compatible correct rule, then versions that accept an equal bid or accept a bid exactly at its deadline. Your tests must pass the reference and fail both incorrect versions through an assertion. Import @std/assert, services/bid-rules.ts, and schemas/bid-schema.ts using @src/ or ../src/ paths. No database helpers or private implementation modules are needed. Keep the invalid-amount, state, and unchanged-input checks.

Working repository and local checks

This is a cumulative Git exercise. Start from the supplied marketplace repository at the preceding accepted milestone; the existing code and migrations are the baseline, not a separate implementation task. Work on master in marketplace/. Paths such as services/... below are relative to api/src/; @src/ resolves to that directory. The API uses Deno, Hono, Zod, sValidator, and postgres.js through api/deno.json. Keep existing features and tests.

This pure test needs no database. Inspect git diff, commit the changed schema, rule function and tests, then git push origin master to submit.

Check and submit

Run the focused check from marketplace/:

Terminal window
docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml run --build --rm --no-deps api deno test --no-check --cached-only tests/bid-rules_test.ts

Type checking is optional in this milestone and does not affect points. The grader assesses executable tests and application behavior.

Points

  • 5: Reject invalid amounts and non-open auctions.
  • 5: Apply the fixed deadline without mutating input.
  • 5: Your tests pass the compatible reference.
  • 5: Your tests detect both equal-bid and inclusive-deadline defects.

Accept bids safely through HTTP

First coordinate the state read, decision, and writes in one locked transaction. Test competing bids, then connect the service to an HTTP handler and test its request boundary. The grader also checks rollback and time spent waiting for a lock.

Marketplace milestone 17: accept bids safely through HTTP

0 / 35 points

Continue after milestone 16: first test the service

In services/auction-service.ts implement placeBid({listingId, bidderId, amount}, readNow = readDatabaseNow). Listing and bidder IDs are validated positive INTEGERs supplied by trusted wiring. Import the shared sql from @src/database.ts. Use auction-repository.ts for queries and the same tx for the whole operation.

Lock the listing. If absent, throw BidRuleError(NOT_FOUND). Read the clock after acquiring the lock and pass the locked state, amount, and time to evaluateBid. Insert an accepted bid using that time, then update current_price, ends_at, updated_at, and version (increment once). Return only after commit:

{ bid: { id, listingId, amount, createdAt },
listing: { id, currentPrice, endsAt, status, version } }

Timestamps are ISO strings and successful status is active. A rule failure or database error leaves both listing and bids unchanged. Complete these service checks before connecting the HTTP route.

Write api/tests/student_bid_service_test.ts using the supplied fixture. Cover ordinary persistence and two equal competing bids using Promise.allSettled. Use an explicit clock for exact service decisions. Settle every started operation before cleanup, and inspect bid rows as well as listing state. The grader checks a request waiting across its deadline; constructing a database blocker or observing PostgreSQL lock waits is not part of your submitted test task.

The grader also forces a database failure after the bid insert and checks that the bid and listing changes roll back together. You implement the transactional operation; constructing that failure-injection test or temporary database constraints is not required in your submitted tests.

Supplied fixture and clock interfaces

Import withAuctionFixture from ../tests/helpers/auction-fixture.ts when writing a file under api/tests/ (equivalently ./helpers/auction-fixture.ts). Call withAuctionFixture(options, async (f) => { ... }). Options are optional startingPrice (default 1000), status (draft or active, default active), and endsAt (Date). For time tests always supply a deadline explicitly. The callback receives sql (the shared application pool), listingId, sellerId, bidderIds (two users), and categoryId. The fixture inserts synthetic rows and deletes its own rows. Await all operations before leaving the callback. Group the database tests as awaited t.step calls inside one Deno.test; close the shared sql in finally after all steps. The fixture does not close the pool.

The service’s optional readNow argument has type (tx: postgres.TransactionSql) => Promise<Date>; tests may pass async () => new Date("2030-01-01T12:00:00.000Z"). Its default, readDatabaseNow from @src/repositories/database-clock.ts, queries PostgreSQL’s current clock with millisecond precision on the supplied transaction. Sample it after the row lock, not when the request arrives.

Bid rules used by this task

evaluateBid(auction, amount, now) from @src/services/bid-rules.ts accepts {status, currentPrice, endsAt: Date | null}, a proposed amount, and a Date. It checks, in order: integer amount 1-2147483647 (INVALID_AMOUNT); active state with a deadline strictly after now (AUCTION_CLOSED); amount strictly greater than currentPrice (BID_TOO_LOW). It throws BidRuleError with that code and returns {currentPrice: amount, endsAt: Date} without mutating inputs or changing the publication deadline. The service adds NOT_FOUND for a missing listing. Two equal competing bids must produce one success and one BID_TOO_LOW, with only one bid row and one listing-version increment.

Then connect the bid HTTP route

Add POST /api/listings/:listingId/bids to the existing route module. Validate path and JSON with sValidator, obtain bidderId from request context, and call placeBid. The strict body contains only amount, a JSON integer 1-2147483647. Import placeBid from @src/services/auction-service.ts and call placeBid({listingId, bidderId, amount}); the service imports the shared database client. Return 201 {bid:{id,listingId,amount,createdAt}, listing:{id,currentPrice,endsAt,status,version}}; timestamps are ISO strings with milliseconds and successful status is active.

Use the HTTP conventions below. Map missing listing to 404 NOT_FOUND, non-open/expired auctions to 409 AUCTION_CLOSED, and low or equal bids to 409 BID_TOO_LOW. Invalid amount syntax/range is rejected by the body schema as 400 INVALID_INPUT. A request cannot select bidderId, currentPrice, endsAt, winningBidId, or a clock. Do not invent a Location URL without a retrieval route for it.

Write raw app.request tests in api/tests/student_bid_http_test.ts. Include malformed JSON, extra fields, an invalid actor, missing media type, and an accepted bid whose stored bidder matches the selected actor. Keep self-bidding and owner permissions outside this local adapter’s contract; it is not a publicly writable application.

Local identity and HTTP conventions

The local actor header is X-Development-User-ID. It must be a complete positive decimal INTEGER ID (1-2147483647, no leading zero) of an existing user whose deleted_at is null. The adapter sets Hono context currentUser: {id}. It was supplied with milestone 14. Keep that adapter unchanged; no environment gate is needed. Mutation checks run in this order: missing/invalid actor → 401 DEVELOPMENT_IDENTITY_REQUIRED; invalid path → 400 INVALID_ID; malformed JSON → 400 INVALID_JSON; invalid body or absent/unsupported JSON content type → 400 INVALID_INPUT. Use sValidator and the existing error adapter. All errors use {error:{code,message}} with a nonempty message; wording is not assessed. Unexpected failures give generic 500 INTERNAL_ERROR. The header is local impersonation, not login or permission checking.

Working repository and local checks

This is a cumulative Git exercise. Start from the supplied marketplace repository at the preceding accepted milestone; the existing code and migrations are the baseline, not a separate implementation task. Work on master in marketplace/. Paths such as services/... below are relative to api/src/; @src/ resolves to that directory. The API uses Deno, Hono, Zod, sValidator, and postgres.js through api/deno.json. Keep existing features and tests.

Docker Compose provides database, database-migrations, api, client, and e2e-tests. compose.test.yaml removes exposed API/client ports and uses temporary database storage. Use both files and a distinct project name for every test command; the API receives DATABASE_URL from Compose before imports. From the repository root, first apply migrations:

Terminal window
docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml run --build --rm database-migrations

Run the focused commands in this handout. At the end, stop only this test project with docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml down. Run suites sequentially. Inspect git diff, commit the requested source/tests and any changed lockfile, and git push origin master to submit. Do not commit secrets, dependencies, builds, or test results.

Check and submit

Run both focused test files from marketplace/:

Terminal window
docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml run --build --rm api deno test --no-check --cached-only --allow-env --allow-net tests/student_bid_service_test.ts tests/student_bid_http_test.ts

Type checking is optional in this milestone and does not affect points. The grader assesses executable tests and application behavior.

Points

  • 10: Persist the accepted bid and listing changes together.
  • 10: Coordinate competing bids and post-lock time.
  • 5: Roll back when a later write fails.
  • 5: Translate accepted/rejected bids through HTTP.
  • 5: Reject invalid input and caller-selected server fields.

Close auctions and verify the application

Apply the same locking and clock principles to closing one listing. Repeated closing must preserve the completed result; the card supplies the candidate loop and execution entry point. Test that guarantee, demonstrate that your test catches a rewritten result, and run the cumulative acceptance checks.

Marketplace milestone 18: close auctions and verify the application

0 / 20 points

Continue after milestone 17

Implement finalizeAuction(listingId, readNow = readDatabaseNow) in services/auction-service.ts. It returns true only when this call closes the listing; return false for missing, already closed, draft, or not-yet-due listings. The service imports the shared sql client. Lock the listing in one transaction, read time after the lock, and recheck its current state and deadline.

A listing is expired when endsAt.getTime() <= now.getTime(). For an expired active listing, select its highest bid (amount descending, then ID ascending). Set winning_bid_id (null with no bids), closed status, updated_at from the sampled time, and increment version once. Keep current_price unchanged. Repeating the operation preserves winner, time, and version. Put queries in auction-repository.ts.

Write tests in api/tests/student_finalization_test.ts for a winner, no bids, just before/at the deadline, and repeated closing. Use a controlled clock and inspect stored values. No scheduler or database-monitoring code is required.

Supplied execution wrapper

Add the following wrapper to the service module. It uses the same repository import as your operation (named persistence here). Implement findClosingCandidates(sql) in the repository as a SELECT of active listing IDs, ordered by ends_at then id and limited to 100. The wrapper chooses candidates; finalizeAuction makes the protected decision for each one.

export async function finalizeExpiredAuctions(readNow: ReadNow = readDatabaseNow) {
const candidates = await persistence.findClosingCandidates(sql);
let closedCount = 0;
for (const candidate of candidates) {
if (await finalizeAuction(candidate.id, readNow)) closedCount++;
}
return {closedCount};
}

Put this supplied entry point in api/src/finalize-auctions.ts:

import { sql } from "./database.ts";
import { finalizeExpiredAuctions } from "./services/auction-service.ts";
try { console.log(await finalizeExpiredAuctions()); }
finally { await sql.end(); }

Run it against the isolated test project:

Terminal window
docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml run --build --rm api deno run --cached-only --allow-env --allow-net src/finalize-auctions.ts

Supplied fixture and clock interfaces

Import withAuctionFixture from ../tests/helpers/auction-fixture.ts when writing a file under api/tests/ (equivalently ./helpers/auction-fixture.ts). Call withAuctionFixture(options, async (f) => { ... }). Options are optional startingPrice (default 1000), status (draft or active, default active), and endsAt (Date). For time tests always supply a deadline explicitly. The callback receives sql (the shared application pool), listingId, sellerId, bidderIds (two users), and categoryId. The fixture inserts synthetic rows and deletes its own rows. Await all operations before leaving the callback. Group the database tests as awaited t.step calls inside one Deno.test; close the shared sql in finally after all steps. The fixture does not close the pool.

The service’s optional readNow argument has type (tx: postgres.TransactionSql) => Promise<Date>; tests may pass async () => new Date("2030-01-01T12:00:00.000Z"). Its default, readDatabaseNow from @src/repositories/database-clock.ts, queries PostgreSQL’s current clock with millisecond precision on the supplied transaction. Sample it after the row lock, not when the request arrives.

Bid rules used by this task

evaluateBid(auction, amount, now) from @src/services/bid-rules.ts accepts {status, currentPrice, endsAt: Date | null}, a proposed amount, and a Date. It checks, in order: integer amount 1-2147483647 (INVALID_AMOUNT); active state with a deadline strictly after now (AUCTION_CLOSED); amount strictly greater than currentPrice (BID_TOO_LOW). It throws BidRuleError with that code and returns {currentPrice: amount, endsAt: Date} without mutating inputs or changing the publication deadline. The service adds NOT_FOUND for a missing listing. Two equal competing bids must produce one success and one BID_TOO_LOW, with only one bid row and one listing-version increment.

Working repository and local checks

This is a cumulative Git exercise. Start from the supplied marketplace repository at the preceding accepted milestone; the existing code and migrations are the baseline, not a separate implementation task. Work on master in marketplace/. Paths such as services/... below are relative to api/src/; @src/ resolves to that directory. The API uses Deno, Hono, Zod, sValidator, and postgres.js through api/deno.json. Keep existing features and tests.

Docker Compose provides database, database-migrations, api, client, and e2e-tests. compose.test.yaml removes exposed API/client ports and uses temporary database storage. Use both files and a distinct project name for every test command; the API receives DATABASE_URL from Compose before imports. From the repository root, first apply migrations:

Terminal window
docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml run --build --rm database-migrations

Run the focused commands in this handout. At the end, stop only this test project with docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml down. Run suites sequentially. Inspect git diff, commit the requested source/tests and any changed lockfile, and git push origin master to submit. Do not commit secrets, dependencies, builds, or test results.

Check and submit

Run the focused check from marketplace/:

Terminal window
docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml run --build --rm api deno test --no-check --cached-only --allow-env --allow-net tests/student_finalization_test.ts

Regression evidence and acceptance

Keep the earlier pure-rule, draft, bid, category, and browser tests. The grader replays student_finalization_test.ts against a compatible reference and an incorrect finalizer that rewrites an already closed result. Your test must pass the reference and reject the defect with an assertion. Import @std/assert, @src/database.ts, the supplied fixture, and the documented auction-service.ts interface (equivalent ../src/ paths are accepted). Avoid private repository helpers. Check winner, version, price, and updated_at before and after a retry.

The pure-rule defect checks were assessed in milestone 16. This final checkpoint runs the cumulative API tests and independent auction checks, including rollback and post-lock timing. Keep the original counter, todos, relationships, category navigation, and browser behavior. Run the complete acceptance sequence:

Terminal window
docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml run --build --rm --no-deps client deno task test
# Optional local type diagnostics; not graded in this milestone.
docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml run --build --rm --no-deps client deno task check
docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml run --build --rm --no-deps client deno task build
docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml run --build --rm api deno task test
docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml run --build --rm e2e-tests
docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml down

Inspect the diff, commit, and push master after these checks pass. Continue to Part 4 from this accepted repository.

Type checking is optional in this milestone and does not affect points. The grader assesses executable tests and application behavior.

Points

  • 5: Record the correct winner and preserve the result on retry.
  • 5: Close no-bid auctions and leave not-yet-due auctions active.
  • 5: Your finalization tests pass the reference and detect a rewritten result.
  • 5: The cumulative API suite and independent auction checks pass.

Keep the repository accepted at milestone 18 for Part 4. Milestone 19 first prepares the browser modules and verifies existing API-backed browsing as a separate submission. Forms then connect to the draft and bid operations. The development actor header remains a local testing aid; Part 5 replaces it with sessions and permission checks.

Check Your Understanding

  1. Why must the auction migration account for listings that already exist?
  2. Which bid decisions can be tested without PostgreSQL, and which require it?
  3. Why should an operation read its decision time after acquiring the listing lock?
  4. What must remain unchanged when a bid fails after its first database write?
  5. Why should running auction finalization twice preserve the first completed result?