본문으로 건너뛰기
개발 뉴스로
Frontenddev.to··원문 약 2분

Cloudflare Workers + D1에 레이스 안전 예약 엔진 구축 (ORM 없음, 날짜 라이브러리 없음)

Building a race-safe booking engine on Cloudflare Workers + D1 (no ORM, no date library)

저는 말레이시아의 상점을 위한 소규모 예약 시스템을 운영하고 있습니다.

핵심 요약

자동 요약
  1. 1저는 말레이시아의 상점을 위한 소규모 예약 시스템을 운영하고 있습니다.
  2. 2이발사, 세차장, 마사지 치료사 등이죠.
  3. 3각 매장은 하나의 링크를 받고 고객은 서비스, 직원, 하루 및 시간을 선택합니다.

원문 본문

출처 · dev.to

I run a small booking system for shops in Malaysia: barbers, car washes, massage therapists. Each shop gets one link, and customers pick a service, a staff member, a day and a time. It runs entirely on Cloudflare Workers + D1 with Hono, and the whole app is about 1,700 lines of TypeScript.

Here are the four parts that took the most thought, with the actual code.

1. "Today" in the business's time zone, without a date library

A shop in New York and a shop in Kuala Lumpur need different answers to "which slots are left today?". Workers support the full Intl API, so this is all you need:

export function nowIn(tz: string): { today: string; minutes: number } { const parts = Object.fromEntries( new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23", }).formatToParts(new Date()).map((p) => [p.type, p.value]), ); return { today: `${parts.year}-${parts.month}-${parts.day}`, minutes: Number(parts.hour) * 60 + Number(parts.minute), }; } 

Dates are stored as YYYY-MM-DD strings and times as HH:MM, both in the shop's local time. String comparison then works for ranges (date BETWEEN ? AND ?), and there are no UTC conversions to get wrong.

2. The slot engine is a pure function

Everything about availability lives in one function with no I/O, which makes it easy to unit test:

for (let t = open; t + duration <= close; t += step) { if (date === today && t <= nowMin) continue; // past times const end = t + duration; if (busyAll.some((x) => overlaps(t, end, x))) continue; // breaks, blocked time for (const sid of staffIds) { // [null] = single chair const clash = bookings.some((b) => (sid === null || b.staff_id === sid || b.staff_id === null) && overlaps(t, end, b)); if (!clash) { out.set(toHHMM(t), sid); break; } // first free staff wins } } 

"Anyone available" just means passing every staff id. The function returns the first free one, which becomes the booking's assigned staff.

The month calendar calls the same function once per day to mark full days. For a 31-day month that's 31 pure calls over data loaded in two queries, which is fast enough that I never needed caching.

3. Preventing double bookings without locks

D1 is SQLite, and requests can't hold a transaction open between "check" and "insert". So I insert first, then check whether I lost the race:

const booking = await db.prepare(`INSERT INTO bookings (...) VALUES (...) RETURNING *`).bind(...).first(); const clash = await db.prepare( `SELECT id FROM bookings WHERE shop_id = ? AND date = ? AND status = 'confirmed' AND id < ? AND (? IS NULL OR staff_id IS NULL OR staff_id = ?) AND ? < start_minutes + duration_min AND start_minutes < ?`, ).bind(...).first(); if (clash) { await db.prepare("UPDATE bookings SET status = 'cancelled' WHERE id = ?").bind(booking.id).run(); return renderBooking(c, shop, "Sorry, someone just took that time. Please choose another."); } 

The rule is "the lower id wins". If two people press Confirm at the same instant, both inserts succeed, but only the second one finds an earlier overlapping row, so only the second is cancelled. Nobody ends up double-booked.

4. Stripe without the SDK

The Stripe Node SDK works on Workers now, but for one Checkout Session and one webhook, plain fetch is smaller and easier to read:

const res = await fetch("https://api.stripe.com/v1/checkout/sessions", { method: "POST", headers: { Authorization: `Bearer ${env.STRIPE_SECRET_KEY}` }, body: new URLSearchParams({ mode: "payment", "line_items[0][quantity]": "1", "line_items[0][price_data][currency]": "usd", "line_items[0][price_data][unit_amount]": "1900", "line_items[0][price_data][product_data][name]": "1 month", success_url: `${origin}/billing/return?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${origin}/dashboard/billing`, }), }); 

Webhook verification is an HMAC with crypto.subtle:

const [t, v1] = parse(header); // "t=...,v1=..." const expected = await hmacHex(env.STRIPE_WEBHOOK_SECRET, `${t}.${payload}`); const ok = Math.abs(Date.now() / 1000 - t) < 300 && timingSafeEqual(v1, expected); 

Even with a valid signature, I re-fetch the session and check payment_status === "paid" before extending an account. The return URL does the same, so whichever arrives first wins, and the UPDATE ... WHERE status = 'pending' RETURNING id guarantees the account is only extended once.

Bonus: a public demo that heals itself

The live demo has a scheduled() handler on an hourly cron. It deletes the demo business and re-seeds it in one db.batch(), using relative dates (date('now', '+1 days')) so there are always upcoming bookings. Visitors can cancel everything and it's back within the hour.

[triggers] crons = ["0 * * * *"] 

Try it

If you've built booking or scheduling on D1 differently, I'd love to hear how you handled races and time zones.

이 글은 dev.to 의 원문을 정제해 보여드립니다. 저작권은 원저작자에게 있습니다.

#cloudflare#typescript#webdev#saas

전체 내용이 궁금하다면

dev.to 원문에서 이어 읽기

원문 보기

비슷한 글

5유사도 추천