From e37316cef882a1db1fddf3fbe6117ecaf3b84362 Mon Sep 17 00:00:00 2001 From: Barkley Trimsworth Date: Mon, 30 Mar 2026 17:12:07 +0000 Subject: [PATCH] fix(db): use deterministic IDs + ON CONFLICT DO UPDATE for services seed seedKnownUsers() inserted demo services with random UUIDs and no idempotency guard, causing duplicates when the seed ran alongside or after the main seed (which uses deterministic IDs + ON CONFLICT DO UPDATE on id). The admin seed API had the same issue. Now both paths use deterministic UUIDs with ON CONFLICT DO UPDATE on id, making the upsert idempotent regardless of seed order or repetition. Same pattern already used for clients (40143c4). Co-Authored-By: Paperclip --- apps/api/src/routes/admin/seed.ts | 28 +++++++++++++--------------- packages/db/src/seed.ts | 30 +++++++++++++++--------------- 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/apps/api/src/routes/admin/seed.ts b/apps/api/src/routes/admin/seed.ts index 58fc0db..d755f02 100644 --- a/apps/api/src/routes/admin/seed.ts +++ b/apps/api/src/routes/admin/seed.ts @@ -37,10 +37,10 @@ const DEMO_PET = { }; const DEMO_SERVICES = [ - { name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 }, - { name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 }, - { name: "Full Groom — Medium", description: "Complete grooming for dogs 25-50 lbs", basePriceCents: 8000, durationMinutes: 75 }, - { name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 }, + { id: "a0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 }, + { id: "a0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 }, + { id: "a0000001-0000-0000-0000-000000000003", name: "Full Groom — Medium", description: "Complete grooming for dogs 25-50 lbs", basePriceCents: 8000, durationMinutes: 75 }, + { id: "a0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 }, ]; adminSeedRouter.post("/seed", async (c) => { @@ -71,18 +71,16 @@ adminSeedRouter.post("/seed", async (c) => { results.push(`Created staff '${KNOWN_STAFF.name}' (id: ${created!.id}, oidcSub: ${KNOWN_STAFF.oidcSub})`); } - // ── Services: only seed if none exist ───────────────────────────────────── - const existingServices = await db.select().from(services).limit(1); - if (existingServices.length > 0) { - results.push("Services already exist — skipping"); - } else { - const created: { id: string; name: string }[] = []; - for (const svc of DEMO_SERVICES) { - const [row] = await db.insert(services).values({ ...svc, active: true }).returning(); - created.push(row!); - } - results.push(`Created ${created.length} services: ${created.map((s) => s.name).join(", ")}`); + // ── Services: idempotent upsert ───────────────────────────────────────────── + for (const svc of DEMO_SERVICES) { + await db.insert(services) + .values({ ...svc, active: true }) + .onConflictDoUpdate({ + target: services.id, + set: { name: svc.name, description: svc.description, basePriceCents: svc.basePriceCents, durationMinutes: svc.durationMinutes, active: true }, + }); } + results.push(`Upserted ${DEMO_SERVICES.length} services`); // ── Client: Demo Client ─────────────────────────────────────────────────── const [existingClient] = await db diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 9000d99..2f01a3b 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -293,22 +293,22 @@ async function seedKnownUsers() { console.log("✓ Created staff 'Demo Manager' (oidcSub: demo-manager-001)"); } - // ── Services: only seed if none exist ── - const existingServices = await db.select().from(schema.services).limit(1); - if (existingServices.length > 0) { - console.log("✓ Services already exist — skipping"); - } else { - const demoSvcs = [ - { name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 }, - { name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 }, - { name: "Full Groom — Medium", description: "Complete grooming for dogs 25-50 lbs", basePriceCents: 8000, durationMinutes: 75 }, - { name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 }, - ]; - for (const svc of demoSvcs) { - await db.insert(schema.services).values({ ...svc, active: true }); - } - console.log(`✓ Created ${demoSvcs.length} services`); + // ── Services: idempotent upsert using deterministic IDs ── + const demoSvcs = [ + { id: "a0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 }, + { id: "a0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", description: "Complete grooming for dogs under 25 lbs", basePriceCents: 6500, durationMinutes: 60 }, + { id: "a0000001-0000-0000-0000-000000000003", name: "Full Groom — Medium", description: "Complete grooming for dogs 25-50 lbs", basePriceCents: 8000, durationMinutes: 75 }, + { id: "a0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 }, + ]; + for (const svc of demoSvcs) { + await db.insert(schema.services) + .values({ ...svc, active: true }) + .onConflictDoUpdate({ + target: schema.services.id, + set: { name: svc.name, description: svc.description, basePriceCents: svc.basePriceCents, durationMinutes: svc.durationMinutes, active: true }, + }); } + console.log(`✓ Seeded ${demoSvcs.length} services`); // ── Client: Demo Client ── const [existingClient] = await db