feat(quick-260406-j44): add idempotent dev seed runner and db:seed:dev script
- Seed runner inserts user, categories, global items, tags, user items, threads with candidates, setups, and settings in FK order - Idempotent: checks for dev-user-seed logtoSub before running - Reuses seedGlobalItems() for base catalog data - Added db:seed:dev npm script to package.json Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,8 @@
|
||||
"test": "bun test tests/",
|
||||
"test:e2e": "bunx playwright test",
|
||||
"test:e2e:ui": "bunx playwright test --ui",
|
||||
"lint": "bunx @biomejs/biome check ."
|
||||
"lint": "bunx @biomejs/biome check .",
|
||||
"db:seed:dev": "bun run src/db/dev-seed.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.7",
|
||||
|
||||
303
src/db/dev-seed.ts
Normal file
303
src/db/dev-seed.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
// ── Dev Seed Runner ────────────────────────────────────────────────
|
||||
// Idempotent script to populate a dev database with realistic data.
|
||||
// Usage: bun run db:seed:dev
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
DEV_CATEGORIES,
|
||||
DEV_GLOBAL_ITEMS,
|
||||
DEV_SETUPS,
|
||||
DEV_SETTINGS,
|
||||
DEV_TAG_ASSIGNMENTS,
|
||||
DEV_THREADS,
|
||||
DEV_USER_ITEMS,
|
||||
categoryDisplayName,
|
||||
} from "./dev-seed-data.ts";
|
||||
import { db } from "./index.ts";
|
||||
import * as schema from "./schema.ts";
|
||||
import { seedGlobalItems } from "./seed-global-items.ts";
|
||||
|
||||
type Db = typeof db;
|
||||
|
||||
async function seedDevData(database: Db = db) {
|
||||
// ── Idempotency check ──────────────────────────────────────────
|
||||
const existing = await database
|
||||
.select()
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.logtoSub, "dev-user-seed"))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
console.log("Dev seed data already exists, skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// ── 1. Seed global items and tags ──────────────────────────
|
||||
await seedGlobalItems(database);
|
||||
console.log(" Global items and tags seeded.");
|
||||
|
||||
// ── 2. Insert dev user ─────────────────────────────────────
|
||||
const [user] = await database
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
logtoSub: "dev-user-seed",
|
||||
displayName: "Dev User",
|
||||
bio: "Bikepacking enthusiast and gear nerd. Always optimizing the kit.",
|
||||
})
|
||||
.returning();
|
||||
if (!user) throw new Error("Failed to insert dev user");
|
||||
const userId = user.id;
|
||||
console.log(` Dev user created (id=${userId}).`);
|
||||
|
||||
// ── 3. Insert categories ───────────────────────────────────
|
||||
const insertedCategories = await database
|
||||
.insert(schema.categories)
|
||||
.values(
|
||||
DEV_CATEGORIES.map((c) => ({
|
||||
name: c.name,
|
||||
icon: c.icon,
|
||||
userId,
|
||||
})),
|
||||
)
|
||||
.returning();
|
||||
|
||||
const categoryByName = new Map<string, number>();
|
||||
for (const cat of insertedCategories) {
|
||||
categoryByName.set(cat.name, cat.id);
|
||||
}
|
||||
console.log(` ${insertedCategories.length} categories created.`);
|
||||
|
||||
// ── 4. Look up tag IDs ─────────────────────────────────────
|
||||
const allTags = await database.select().from(schema.tags);
|
||||
const tagNameToId = new Map<string, number>();
|
||||
for (const tag of allTags) {
|
||||
tagNameToId.set(tag.name, tag.id);
|
||||
}
|
||||
|
||||
// ── 5. Insert global items and tag assignments ─────────────
|
||||
// DEV_GLOBAL_ITEMS may overlap with seed-global-items.json entries.
|
||||
// Insert only items that don't already exist (by brand+model).
|
||||
const existingGlobalItems = await database
|
||||
.select()
|
||||
.from(schema.globalItems);
|
||||
const existingGlobalItemMap = new Map<string, number>();
|
||||
for (const gi of existingGlobalItems) {
|
||||
existingGlobalItemMap.set(`${gi.brand}::${gi.model}`, gi.id);
|
||||
}
|
||||
|
||||
const globalItemIds: number[] = [];
|
||||
let newGlobalCount = 0;
|
||||
|
||||
for (const item of DEV_GLOBAL_ITEMS) {
|
||||
const key = `${item.brand}::${item.model}`;
|
||||
const existingId = existingGlobalItemMap.get(key);
|
||||
if (existingId) {
|
||||
globalItemIds.push(existingId);
|
||||
} else {
|
||||
const [inserted] = await database
|
||||
.insert(schema.globalItems)
|
||||
.values({
|
||||
brand: item.brand,
|
||||
model: item.model,
|
||||
category: item.category,
|
||||
weightGrams: item.weightGrams,
|
||||
priceCents: item.priceCents,
|
||||
description: item.description,
|
||||
})
|
||||
.returning();
|
||||
if (!inserted) throw new Error(`Failed to insert global item: ${item.brand} ${item.model}`);
|
||||
globalItemIds.push(inserted.id);
|
||||
newGlobalCount++;
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
` ${globalItemIds.length} global items mapped (${newGlobalCount} new).`,
|
||||
);
|
||||
|
||||
// Insert tag assignments
|
||||
let tagAssignmentCount = 0;
|
||||
for (const assignment of DEV_TAG_ASSIGNMENTS) {
|
||||
const giId = globalItemIds[assignment.globalItemIndex];
|
||||
if (!giId) continue;
|
||||
for (const tagName of assignment.tagNames) {
|
||||
const tagId = tagNameToId.get(tagName);
|
||||
if (!tagId) continue;
|
||||
// Skip if already exists
|
||||
const existingLink = await database
|
||||
.select()
|
||||
.from(schema.globalItemTags)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.globalItemTags.globalItemId, giId),
|
||||
eq(schema.globalItemTags.tagId, tagId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (existingLink.length === 0) {
|
||||
await database
|
||||
.insert(schema.globalItemTags)
|
||||
.values({ globalItemId: giId, tagId });
|
||||
tagAssignmentCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(` ${tagAssignmentCount} tag assignments created.`);
|
||||
|
||||
// ── 6. Insert user items ───────────────────────────────────
|
||||
const userItemValues = DEV_USER_ITEMS.map((item) => ({
|
||||
name: item.name,
|
||||
weightGrams: item.weightGrams,
|
||||
priceCents: item.priceCents,
|
||||
categoryId: categoryByName.get(item.categoryName)!,
|
||||
userId,
|
||||
notes: item.notes,
|
||||
quantity: item.quantity,
|
||||
globalItemId:
|
||||
item.globalItemIndex !== null
|
||||
? globalItemIds[item.globalItemIndex]
|
||||
: null,
|
||||
purchasePriceCents: item.purchasePriceCents,
|
||||
}));
|
||||
|
||||
const insertedItems = await database
|
||||
.insert(schema.items)
|
||||
.values(userItemValues)
|
||||
.returning();
|
||||
console.log(` ${insertedItems.length} user items created.`);
|
||||
|
||||
// ── 7. Insert threads ──────────────────────────────────────
|
||||
const threadResults: Array<{
|
||||
threadId: number;
|
||||
threadDef: (typeof DEV_THREADS)[number];
|
||||
}> = [];
|
||||
|
||||
for (const threadDef of DEV_THREADS) {
|
||||
const catId = categoryByName.get(threadDef.categoryName);
|
||||
if (!catId) continue;
|
||||
|
||||
const [thread] = await database
|
||||
.insert(schema.threads)
|
||||
.values({
|
||||
name: threadDef.name,
|
||||
status: threadDef.status,
|
||||
categoryId: catId,
|
||||
userId,
|
||||
})
|
||||
.returning();
|
||||
if (!thread) throw new Error(`Failed to insert thread: ${threadDef.name}`);
|
||||
threadResults.push({ threadId: thread.id, threadDef });
|
||||
}
|
||||
console.log(` ${threadResults.length} threads created.`);
|
||||
|
||||
// ── 8. Insert thread candidates ────────────────────────────
|
||||
let candidateCount = 0;
|
||||
for (const { threadId, threadDef } of threadResults) {
|
||||
const catId = categoryByName.get(threadDef.categoryName)!;
|
||||
|
||||
const insertedCandidates = [];
|
||||
for (const cand of threadDef.candidates) {
|
||||
const [inserted] = await database
|
||||
.insert(schema.threadCandidates)
|
||||
.values({
|
||||
threadId,
|
||||
name: cand.name,
|
||||
weightGrams: cand.weightGrams,
|
||||
priceCents: cand.priceCents,
|
||||
categoryId: catId,
|
||||
status: cand.status,
|
||||
pros: cand.pros,
|
||||
cons: cand.cons,
|
||||
notes: cand.notes,
|
||||
sortOrder: cand.sortOrder,
|
||||
globalItemId:
|
||||
cand.globalItemIndex !== null
|
||||
? globalItemIds[cand.globalItemIndex]
|
||||
: null,
|
||||
})
|
||||
.returning();
|
||||
insertedCandidates.push(inserted);
|
||||
candidateCount++;
|
||||
}
|
||||
|
||||
// Set resolvedCandidateId for resolved threads
|
||||
if (
|
||||
"resolvedCandidateIndex" in threadDef &&
|
||||
threadDef.resolvedCandidateIndex !== undefined
|
||||
) {
|
||||
const winnerCandidate =
|
||||
insertedCandidates[threadDef.resolvedCandidateIndex];
|
||||
if (winnerCandidate) {
|
||||
await database
|
||||
.update(schema.threads)
|
||||
.set({ resolvedCandidateId: winnerCandidate.id })
|
||||
.where(eq(schema.threads.id, threadId));
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(` ${candidateCount} thread candidates created.`);
|
||||
|
||||
// ── 9. Insert setups ───────────────────────────────────────
|
||||
const setupResults: Array<{
|
||||
setupId: number;
|
||||
setupDef: (typeof DEV_SETUPS)[number];
|
||||
}> = [];
|
||||
|
||||
for (const setupDef of DEV_SETUPS) {
|
||||
const [setup] = await database
|
||||
.insert(schema.setups)
|
||||
.values({
|
||||
name: setupDef.name,
|
||||
userId,
|
||||
isPublic: setupDef.isPublic,
|
||||
})
|
||||
.returning();
|
||||
if (!setup) throw new Error(`Failed to insert setup: ${setupDef.name}`);
|
||||
setupResults.push({ setupId: setup.id, setupDef });
|
||||
}
|
||||
console.log(` ${setupResults.length} setups created.`);
|
||||
|
||||
// ── 10. Insert setup items ─────────────────────────────────
|
||||
let setupItemCount = 0;
|
||||
for (const { setupId, setupDef } of setupResults) {
|
||||
for (const si of setupDef.items) {
|
||||
const userItem = insertedItems[si.userItemIndex];
|
||||
if (!userItem) continue;
|
||||
await database.insert(schema.setupItems).values({
|
||||
setupId,
|
||||
itemId: userItem.id,
|
||||
classification: si.classification,
|
||||
});
|
||||
setupItemCount++;
|
||||
}
|
||||
}
|
||||
console.log(` ${setupItemCount} setup items created.`);
|
||||
|
||||
// ── 11. Insert settings ────────────────────────────────────
|
||||
for (const setting of DEV_SETTINGS) {
|
||||
await database.insert(schema.settings).values({
|
||||
userId,
|
||||
key: setting.key,
|
||||
value: setting.value,
|
||||
});
|
||||
}
|
||||
console.log(` ${DEV_SETTINGS.length} settings created.`);
|
||||
|
||||
// ── Summary ────────────────────────────────────────────────
|
||||
console.log(
|
||||
`\nDev seed complete: ${globalItemIds.length} global items, ${allTags.length} tags, ${insertedItems.length} user items, ${threadResults.length} threads, ${setupResults.length} setups`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Seed failed:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Entry point ────────────────────────────────────────────────────
|
||||
|
||||
seedDevData()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error("Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user