1. Increase garminOauth1Token and garminOauth2Token max length from 5000 to 20000 characters to accommodate encrypted OAuth tokens. Add logic to update existing field constraints in addUserFields(). 2. Fix flaky pocketbase-harness e2e test by adding retry logic with exponential backoff to createAdminUser() and createTestUser(). Handles SQLite database lock during PocketBase startup migrations. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
371 lines
9.6 KiB
TypeScript
371 lines
9.6 KiB
TypeScript
// 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.
|
|
*/
|
|
export interface CollectionField {
|
|
name: string;
|
|
type: string;
|
|
required?: boolean;
|
|
// Text field max length (PocketBase defaults to 5000 if not specified)
|
|
max?: number;
|
|
// Relation field properties (top-level, not in options)
|
|
collectionId?: string;
|
|
maxSelect?: number;
|
|
cascadeDelete?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Collection definition for PocketBase.
|
|
*/
|
|
export 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];
|
|
|
|
/**
|
|
* Custom fields to add to the users collection.
|
|
* These are required for Garmin integration and app functionality.
|
|
*/
|
|
export const USER_CUSTOM_FIELDS: CollectionField[] = [
|
|
{ name: "garminConnected", type: "bool" },
|
|
{ name: "garminOauth1Token", type: "text", max: 20000 },
|
|
{ name: "garminOauth2Token", type: "text", max: 20000 },
|
|
{ name: "garminTokenExpiresAt", type: "date" },
|
|
{ name: "calendarToken", type: "text" },
|
|
{ name: "lastPeriodDate", type: "date" },
|
|
{ name: "cycleLength", type: "number" },
|
|
{ name: "notificationTime", type: "text" },
|
|
{ name: "timezone", type: "text" },
|
|
{ name: "activeOverrides", type: "json" },
|
|
];
|
|
|
|
/**
|
|
* Adds or updates custom fields on the users collection.
|
|
* For new fields: adds them. For existing fields: updates max constraint if different.
|
|
* This is idempotent - safe to run multiple times.
|
|
*/
|
|
export async function addUserFields(pb: PocketBase): Promise<void> {
|
|
const usersCollection = await pb.collections.getOne("users");
|
|
|
|
// Build a map of existing fields by name
|
|
const existingFieldsMap = new Map<string, Record<string, unknown>>(
|
|
(usersCollection.fields || []).map((f: Record<string, unknown>) => [
|
|
f.name as string,
|
|
f,
|
|
]),
|
|
);
|
|
|
|
// Separate new fields from fields that need updating
|
|
const newFields: CollectionField[] = [];
|
|
const fieldsToUpdate: string[] = [];
|
|
|
|
for (const definedField of USER_CUSTOM_FIELDS) {
|
|
const existingField = existingFieldsMap.get(definedField.name);
|
|
if (!existingField) {
|
|
newFields.push(definedField);
|
|
} else if (
|
|
definedField.max !== undefined &&
|
|
existingField.max !== definedField.max
|
|
) {
|
|
fieldsToUpdate.push(definedField.name);
|
|
existingField.max = definedField.max;
|
|
}
|
|
}
|
|
|
|
const hasChanges = newFields.length > 0 || fieldsToUpdate.length > 0;
|
|
|
|
if (hasChanges) {
|
|
// Combine existing fields (with updates) and new fields
|
|
const allFields = [...(usersCollection.fields || []), ...newFields];
|
|
|
|
await pb.collections.update(usersCollection.id, {
|
|
fields: allFields,
|
|
});
|
|
|
|
if (newFields.length > 0) {
|
|
console.log(
|
|
` Added ${newFields.length} field(s) to users:`,
|
|
newFields.map((f) => f.name),
|
|
);
|
|
}
|
|
if (fieldsToUpdate.length > 0) {
|
|
console.log(
|
|
` Updated max constraint for ${fieldsToUpdate.length} field(s):`,
|
|
fieldsToUpdate,
|
|
);
|
|
}
|
|
} else {
|
|
console.log(" All user fields already exist with correct settings.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the names of existing collections from PocketBase.
|
|
*/
|
|
export async function getExistingCollectionNames(
|
|
pb: PocketBase,
|
|
): Promise<string[]> {
|
|
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<string> {
|
|
const collection = await pb.collections.getOne(nameOrId);
|
|
return collection.id;
|
|
}
|
|
|
|
/**
|
|
* Creates a collection in PocketBase.
|
|
*/
|
|
export async function createCollection(
|
|
pb: PocketBase,
|
|
collection: CollectionDefinition,
|
|
): Promise<void> {
|
|
// Resolve any collection names to actual IDs for relation fields
|
|
const fields = await Promise.all(
|
|
collection.fields.map(async (field) => {
|
|
const baseField: Record<string, unknown> = {
|
|
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<void> {
|
|
// 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);
|
|
}
|
|
|
|
// Add custom fields to users collection
|
|
console.log("Checking users collection fields...");
|
|
await addUserFields(pb);
|
|
|
|
// 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);
|
|
});
|
|
}
|