fix(db): add migration 0020 for UNIQUE(name) + align admin seed ON CONFLICT
Problem:
- Schema change in eacf8ab added .unique() to services.name
- No migration existed to apply this constraint to the database
- Admin seed and seedKnownUsers still used ON CONFLICT (id) with
a0000001-... IDs, inconsistent with main seed's b0000001-... IDs
and ON CONFLICT (name)
Fix:
- Migration 0020: clean up existing duplicate services (keep lowest
id per name), then add UNIQUE constraint on services.name
- Admin seed (apps/api): switch to ON CONFLICT (name) and b0000001
IDs to match main seed's servicesDef
- seedKnownUsers (packages/db): same alignment — b0000001 IDs and
ON CONFLICT (name)
GRO-364: ON CONFLICT (name) eliminates the duplicate-row problem
that the old dedup+ON CONFLICT(id) approach could not solve when
existing rows had non-deterministic IDs.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -37,10 +37,10 @@ const DEMO_PET = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DEMO_SERVICES = [
|
const DEMO_SERVICES = [
|
||||||
{ id: "a0000001-0000-0000-0000-000000000001", name: "Bath & Brush", description: "Full bath, blow-dry, brush out, and ear cleaning", basePriceCents: 4500, durationMinutes: 45 },
|
{ id: "b0000001-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: "b0000001-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: "b0000001-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 },
|
{ id: "b0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
|
||||||
];
|
];
|
||||||
|
|
||||||
adminSeedRouter.post("/seed", async (c) => {
|
adminSeedRouter.post("/seed", async (c) => {
|
||||||
@@ -71,13 +71,16 @@ adminSeedRouter.post("/seed", async (c) => {
|
|||||||
results.push(`Created staff '${KNOWN_STAFF.name}' (id: ${created!.id}, oidcSub: ${KNOWN_STAFF.oidcSub})`);
|
results.push(`Created staff '${KNOWN_STAFF.name}' (id: ${created!.id}, oidcSub: ${KNOWN_STAFF.oidcSub})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Services: idempotent upsert ─────────────────────────────────────────────
|
// ── Services: idempotent upsert using name as unique key ────────────────────
|
||||||
|
// NOTE: UNIQUE constraint on services.name must exist (via migration 0020).
|
||||||
|
// Both this admin seed and the main DB seed use the same deterministic IDs
|
||||||
|
// and ON CONFLICT (name), ensuring consistency across both seed paths.
|
||||||
for (const svc of DEMO_SERVICES) {
|
for (const svc of DEMO_SERVICES) {
|
||||||
await db.insert(services)
|
await db.insert(services)
|
||||||
.values({ ...svc, active: true })
|
.values({ ...svc, active: true })
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: services.id,
|
target: services.name,
|
||||||
set: { name: svc.name, description: svc.description, basePriceCents: svc.basePriceCents, durationMinutes: svc.durationMinutes, active: true },
|
set: { description: svc.description, basePriceCents: svc.basePriceCents, durationMinutes: svc.durationMinutes, active: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
results.push(`Upserted ${DEMO_SERVICES.length} services`);
|
results.push(`Upserted ${DEMO_SERVICES.length} services`);
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Clean up existing duplicate services before adding unique constraint.
|
||||||
|
-- Keep the row with the lowest id per name; delete all others.
|
||||||
|
DELETE FROM services WHERE id NOT IN (
|
||||||
|
SELECT (MIN(id::text))::uuid FROM services GROUP BY name
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "services" ADD CONSTRAINT "services_name_unique" UNIQUE("name");
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -141,6 +141,13 @@
|
|||||||
"when": 1774729055924,
|
"when": 1774729055924,
|
||||||
"tag": "0019_concerned_sunfire",
|
"tag": "0019_concerned_sunfire",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 20,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775050467192,
|
||||||
|
"tag": "0020_typical_daimon_hellstrom",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+11
-8
@@ -234,7 +234,8 @@ const productsUsed = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// ── Service definitions ──────────────────────────────────────────────────────
|
// ── Service definitions ──────────────────────────────────────────────────────
|
||||||
// Deterministic service IDs so seed is idempotent (ON CONFLICT targets id, not name).
|
// Deterministic service IDs + UNIQUE(name) constraint make seed fully idempotent:
|
||||||
|
// first run inserts, subsequent runs update existing rows via ON CONFLICT (name).
|
||||||
const servicesDef = [
|
const servicesDef = [
|
||||||
{ id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", desc: "Full bath, blow-dry, brush out, and ear cleaning", price: 4500, dur: 45 },
|
{ id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", desc: "Full bath, blow-dry, brush out, and ear cleaning", price: 4500, dur: 45 },
|
||||||
{ id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", desc: "Complete grooming for dogs under 25 lbs", price: 6500, dur: 60 },
|
{ id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", desc: "Complete grooming for dogs under 25 lbs", price: 6500, dur: 60 },
|
||||||
@@ -293,19 +294,21 @@ async function seedKnownUsers() {
|
|||||||
console.log("✓ Created staff 'Demo Manager' (oidcSub: demo-manager-001)");
|
console.log("✓ Created staff 'Demo Manager' (oidcSub: demo-manager-001)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Services: idempotent upsert using deterministic IDs ──
|
// ── Services: idempotent upsert using name as unique key ─────────────────────
|
||||||
|
// UNIQUE constraint on services.name (migration 0020) must exist first.
|
||||||
|
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
|
||||||
const demoSvcs = [
|
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: "b0000001-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: "b0000001-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: "b0000001-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 },
|
{ id: "b0000001-0000-0000-0000-000000000004", name: "Nail Trim", description: "Nail clipping and filing", basePriceCents: 1500, durationMinutes: 15 },
|
||||||
];
|
];
|
||||||
for (const svc of demoSvcs) {
|
for (const svc of demoSvcs) {
|
||||||
await db.insert(schema.services)
|
await db.insert(schema.services)
|
||||||
.values({ ...svc, active: true })
|
.values({ ...svc, active: true })
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: schema.services.id,
|
target: schema.services.name,
|
||||||
set: { name: svc.name, description: svc.description, basePriceCents: svc.basePriceCents, durationMinutes: svc.durationMinutes, active: true },
|
set: { description: svc.description, basePriceCents: svc.basePriceCents, durationMinutes: svc.durationMinutes, active: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
console.log(`✓ Seeded ${demoSvcs.length} services`);
|
console.log(`✓ Seeded ${demoSvcs.length} services`);
|
||||||
|
|||||||
Reference in New Issue
Block a user