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:
docker compose run --rm --no-deps --user "$(id -u):$(id -g)" -e DENO_DIR=/tmp/deno-cache --volume ./api:/app api deno installOn 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:
| Column | Type and nullability | Default / existing records |
|---|---|---|
| current_price | INTEGER NOT NULL | Backfill from starting_price; future inserts supply it |
| status | TEXT NOT NULL | Default and backfill draft |
| version | INTEGER NOT NULL | Default and backfill 1 |
| ends_at | TIMESTAMPTZ(3), nullable | Null for existing drafts |
| updated_at | TIMESTAMPTZ(3) NOT NULL | Default CURRENT_TIMESTAMP |
| winning_bid_id | INTEGER, nullable | Null |
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 responsibility | Destination |
|---|---|
| Listing collection and detail queries | api/src/repositories/listing-repository.ts |
| Listing routes, including the contributed detail route | api/src/routes/public-api.ts |
| Listing ID schema | api/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/.
docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml run --build --rm database-migrationsInspect dbmate status, the preserved seed rows, and accepted/rejected inserts in that database. Reset this test project when migrations change:
docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml downLater 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:
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.tsType 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:
git fetch origingit diff --stat HEAD..origin/milestone-14git diff HEAD..origin/milestone-14git merge --ff-only origin/milestone-14If 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:
docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml run --build --rm database-migrationsRun 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/:
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.tsType 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:
docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml run --build --rm database-migrationsRun 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/:
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.tsType 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:
- Validate
{amount}with BidInputSchema.safeParse; reject INVALID_AMOUNT. - Require active state, a deadline, and now strictly before it; otherwise AUCTION_CLOSED.
- 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/:
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.tsType 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:
docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml run --build --rm database-migrationsRun 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/:
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.tsType 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:
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.tsSupplied 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:
docker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml run --build --rm database-migrationsRun 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/:
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.tsRegression 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:
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 checkdocker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml run --build --rm --no-deps client deno task builddocker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml run --build --rm api deno task testdocker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml run --build --rm e2e-testsdocker compose -p wsd-marketplace-test -f compose.yaml -f compose.test.yaml downInspect 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
- Why must the auction migration account for listings that already exist?
- Which bid decisions can be tested without PostgreSQL, and which require it?
- Why should an operation read its decision time after acquiring the listing lock?
- What must remain unchanged when a bid fails after its first database write?
- Why should running auction finalization twice preserve the first completed result?