// ABOUTME: Database setup script for creating PocketBase collections. // ABOUTME: Run with: POCKETBASE_ADMIN_EMAIL=... POCKETBASE_ADMIN_PASSWORD=... pnpm db:setup import PocketBase from "pocketbase"; /** * Collection field definition for PocketBase. * For relation fields, collectionId/maxSelect/cascadeDelete are top-level properties. */ interface CollectionField { name: string; type: string; required?: boolean; // Relation field properties (top-level, not in options) collectionId?: string; maxSelect?: number; cascadeDelete?: boolean; } /** * Collection definition for PocketBase. */ interface CollectionDefinition { name: string; type: string; fields: CollectionField[]; } /** * Period logs collection schema - tracks menstrual cycle start dates. * Note: collectionId will be resolved at runtime to the actual users collection ID. */ export const PERIOD_LOGS_COLLECTION: CollectionDefinition = { name: "period_logs", type: "base", fields: [ { name: "user", type: "relation", required: true, collectionId: "users", // Will be resolved to actual ID at runtime maxSelect: 1, cascadeDelete: true, }, { name: "startDate", type: "date", required: true, }, { name: "predictedDate", type: "date", required: false, }, ], }; /** * Daily logs collection schema - daily training snapshots with biometrics. * Note: collectionId will be resolved at runtime to the actual users collection ID. */ export const DAILY_LOGS_COLLECTION: CollectionDefinition = { name: "dailyLogs", type: "base", fields: [ { name: "user", type: "relation", required: true, collectionId: "users", // Will be resolved to actual ID at runtime maxSelect: 1, cascadeDelete: true, }, { name: "date", type: "date", required: true, }, { name: "cycleDay", type: "number", required: true, }, { name: "phase", type: "text", required: true, }, { name: "bodyBatteryCurrent", type: "number", required: false, }, { name: "bodyBatteryYesterdayLow", type: "number", required: false, }, { name: "hrvStatus", type: "text", required: false, }, { name: "weekIntensityMinutes", type: "number", required: false, }, { name: "phaseLimit", type: "number", required: false, }, { name: "remainingMinutes", type: "number", required: false, }, { name: "trainingDecision", type: "text", required: true, }, { name: "decisionReason", type: "text", required: true, }, { name: "notificationSentAt", type: "date", required: false, }, ], }; /** * All collections that should exist in the database. */ const REQUIRED_COLLECTIONS = [PERIOD_LOGS_COLLECTION, DAILY_LOGS_COLLECTION]; /** * Gets the names of existing collections from PocketBase. */ export async function getExistingCollectionNames( pb: PocketBase, ): Promise { const collections = await pb.collections.getFullList(); return collections.map((c) => c.name); } /** * Returns collection definitions that don't exist in the database. */ export function getMissingCollections( existingNames: string[], ): CollectionDefinition[] { return REQUIRED_COLLECTIONS.filter( (collection) => !existingNames.includes(collection.name), ); } /** * Resolves collection name to actual collection ID. */ async function resolveCollectionId( pb: PocketBase, nameOrId: string, ): Promise { const collection = await pb.collections.getOne(nameOrId); return collection.id; } /** * Creates a collection in PocketBase. */ async function createCollection( pb: PocketBase, collection: CollectionDefinition, ): Promise { // Resolve any collection names to actual IDs for relation fields const fields = await Promise.all( collection.fields.map(async (field) => { const baseField: Record = { name: field.name, type: field.type, required: field.required ?? false, }; // For relation fields, resolve collectionId and add relation-specific props if (field.type === "relation" && field.collectionId) { const resolvedId = await resolveCollectionId(pb, field.collectionId); baseField.collectionId = resolvedId; baseField.maxSelect = field.maxSelect ?? 1; baseField.cascadeDelete = field.cascadeDelete ?? false; } return baseField; }), ); await pb.collections.create({ name: collection.name, type: collection.type, fields, }); } /** * Main setup function - creates missing collections. */ async function main(): Promise { // Validate environment const pocketbaseUrl = process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://localhost:8090"; const adminEmail = process.env.POCKETBASE_ADMIN_EMAIL; const adminPassword = process.env.POCKETBASE_ADMIN_PASSWORD; if (!adminEmail || !adminPassword) { console.error( "Error: POCKETBASE_ADMIN_EMAIL and POCKETBASE_ADMIN_PASSWORD are required", ); console.error("Usage:"); console.error( " POCKETBASE_ADMIN_EMAIL=admin@example.com POCKETBASE_ADMIN_PASSWORD=secret pnpm db:setup", ); process.exit(1); } console.log(`Connecting to PocketBase at ${pocketbaseUrl}...`); const pb = new PocketBase(pocketbaseUrl); pb.autoCancellation(false); // Authenticate as admin try { await pb .collection("_superusers") .authWithPassword(adminEmail, adminPassword); console.log("Authenticated as admin"); } catch (error) { console.error("Failed to authenticate as admin:", error); process.exit(1); } // Get existing collections const existingNames = await getExistingCollectionNames(pb); console.log( `Found ${existingNames.length} existing collections:`, existingNames, ); // Find and create missing collections const missing = getMissingCollections(existingNames); if (missing.length === 0) { console.log("All required collections already exist. Nothing to do."); return; } console.log( `Creating ${missing.length} missing collection(s):`, missing.map((c) => c.name), ); for (const collection of missing) { try { await createCollection(pb, collection); console.log(` Created: ${collection.name}`); } catch (error) { console.error(` Failed to create ${collection.name}:`, error); process.exit(1); } } console.log("Database setup complete!"); } // Run main function when executed directly const isMainModule = typeof require !== "undefined" && require.main === module; // For ES modules / tsx execution if (isMainModule || process.argv[1]?.includes("setup-db")) { main().catch((error) => { console.error("Setup failed:", error); process.exit(1); }); }