Skip to content

eusahn/slate

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Slate — real-time reservation platform

A GraphQL-first booking system (the core of an OpenTable / Calendly): venues publish time slots, authenticated users claim them, no two users can ever book the same slot, and every watcher sees a slot flip to BOOKED the instant it's claimed. Built on AWS AppSync with DynamoDB single-table design.

AWS AppSync (GraphQL) is the only front door. Reads resolve straight to DynamoDB with no Lambda; booking runs as a Step Functions saga; DynamoDB Streams power live subscriptions. Cognito authenticates every request. Runs for ~$0/month, is account-agnostic (clone and apply into any AWS account), and is designed to be terraform destroyed between sessions.

Architecture

                       Cognito user pool ── JWT ──┐
                                                  ▼
  client ──GraphQL/HTTPS──► AppSync ──┬─ venues / venueSlots / myBookings ─► DynamoDB
   (queries, mutations)    │          │     (APPSYNC_JS resolvers,      (single table,
            ▲              │          │      direct to DynamoDB)         GSI1, TTL, Stream)
            │  onSlotUpdate│          └─ bookSlot ─► Lambda ─► Step Functions (Express, sync)
            │ (subscription)│                                  PlaceHold → TakePayment → ConfirmBooking
            │              │                                      └ payment fails → ReleaseHold (compensate)
            │              ▼                                               │
            │   publishSlotUpdate (NONE,                       DynamoDB Streams (status change / TTL expiry)
            └──── @aws_iam) ◄── Streams Lambda ◄───────────────────────────┘
                                  (SigV4) — also reverts TTL-expired holds to OPEN

A booking never double-books: PlaceHold and ConfirmBooking are conditional TransactWriteItems. If payment fails, the saga's ReleaseHold compensation returns the slot to OPEN. If a hold's 15-minute TTL fires first, DynamoDB deletes it, the Streams Lambda sees the deletion and reverts the slot — and that write re-enters the stream, so the "open again" update is published for free.

Everything lives in one DynamoDB table. The sort-key prefix plus one GSI cover every access pattern:

pk             sk                          entity      gsi1pk   gsi1sk
VENUE#<vid>    META                        Venue       VENUE    <name>
VENUE#<vid>    SLOT#<startsAt>             Slot        —        —
BOOKING#<bid>  META                        Booking     —        —
USER#<sub>     BOOKING#<createdAt>#<bid>   BookingRef  —        —
Access pattern How
list venues Query GSI1 (gsi1pk = "VENUE"), alphabetical — never scans the table
a venue's slots Query base table (pk = VENUE#vid, sk begins_with "SLOT#"), chronological
my bookings Query base table (pk = USER#<sub>), newest first — partition key is the caller's identity
book a slot TransactWriteItems: conditional slot update (status = OPEN) + booking + user ref

The booking transaction is the heart of it: two callers racing for the same slot both submit the transaction, but only one satisfies status = OPEN. The loser's condition fails, DynamoDB cancels the whole transaction (no orphan booking row), and the resolver returns slot unavailable.

Quickstart

make venv        # local dev deps (moto, pytest, websockets)
make test        # unit tests — in-memory DynamoDB via moto, no AWS needed
make bootstrap   # one-time: create the S3 remote-state bucket (survives destroys)
make init        # connect the dev stack to the state bucket (name derived from your account)
make apply       # deploy (~90s)
make seed        # load a demo venue + open slots
make smoke       # end-to-end GraphQL test (Cognito user, booking saga, compensation, …)
make destroy     # tear it all down (state bucket survives for next session)

See the real-time path in action: run make watch in one terminal (live onSlotUpdate subscription), then make smoke in another and watch slots flip OPEN → BOOKED as they're claimed.

Frontend demo (web/)

A React + Vite app (AWS Amplify for Cognito auth + AppSync, including the subscription WebSocket) that shows the whole thing in a browser. After the stack is applied:

make demo        # seeds data + demo users, writes web config, starts the dev server
# → open http://localhost:5173, sign in with a demo login shown on the page

make demo bundles make seed + make seed-users + make web-config (which generates web/.env.local from the terraform outputs) and runs npm run dev. First time only: cd web && npm install.

The money demo: open the app in two browsers, sign in as [email protected] and [email protected], and book a slot in one — it flips to BOOKED in the other instantly (the onSlotUpdate subscription). Tick Simulate payment failure before booking to watch the saga's compensation: the booking is rejected and the slot stays OPEN.

The frontend always runs locally (Vite dev server) pointed at whatever stack is currently deployed — there's nothing to host while the backend is torn down between sessions.

To remove everything including the state bucket, use make destroy-all (dev stack first, then bucket).

Requires: Terraform ≥ 1.10, AWS credentials, curl, python3, AWS CLI v2.

Local development

There's no local emulator for AppSync, so the loop is logic-local, AppSync-on-AWS: all the Lambda logic (the saga starter, the payment mock, and the Streams processor — including the conditional hold-revert) is unit-tested against in-memory DynamoDB with moto via make test (fast, free, no AWS). The thin AppSync layer — resolvers, Cognito auth, subscriptions — is integration- tested against a deployed stack via make smoke and make watch.

GraphQL API

type Query {
  venues: [Venue!]!
  venueSlots(venueId: ID!): [Slot!]!
  myBookings: [Booking!]!                                    # scoped to the caller's Cognito sub
}

type Mutation {
  bookSlot(venueId: ID!, slotId: ID!, failPayment: Boolean): Booking!  # runs the saga
  publishSlotUpdate(slot: SlotInput!): Slot @aws_iam         # system-only (Streams Lambda)
}

type Subscription {
  onSlotUpdate(venueId: ID!): Slot @aws_subscribe(mutations: ["publishSlotUpdate"])
}

failPayment: true forces the saga's payment step to decline — a demo affordance for exercising the compensation path. Every client request carries a Cognito user-pool JWT in the Authorization header. Resolvers read the caller from ctx.identity.sub, so a user can only ever see their own bookings — the partition key is derived from the verified token, not from a client-supplied argument.

Call it by hand once deployed:

GQL=$(terraform -chdir=terraform/envs/dev output -raw graphql_url)
# (see scripts/smoke.sh for getting a TOKEN from Cognito)
curl -s -X POST "$GQL" -H "Authorization: $TOKEN" -H 'content-type: application/json' \
  -d '{"query":"query{ venues{ venueId name } }"}'

How it was built

Slate grew in three green, independently deployable checkpoints:

  • Phase 1 ✅ single-table + Cognito + AppSync JS resolvers + an atomic bookSlot (conditional TransactWriteItems).
  • Phase 2 ✅ DynamoDB Streams → a Lambda → publishSlotUpdate (IAM-authed, NONE data source) → live onSlotUpdate(venueId) GraphQL subscriptions. TTL holds; an expired hold's stream event reverts the slot to OPEN.
  • Phase 3bookSlot is now a Step Functions Express saga (hold → pay → confirm) with a mock payment and compensation (release the hold when payment fails). The conditional holds are issued via Step Functions' DynamoDB SDK integration — no per-step Lambda.

Cost & teardown

PAY_PER_REQUEST DynamoDB, AppSync, and Cognito are all pay-per-use, so an idle stack costs ~$0. CloudWatch log groups are created explicitly with 7-day retention so they don't linger after terraform destroy. Tear down between sessions; the state bucket is the only thing that persists.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors