diff --git a/e2e/pocketbase-harness.ts b/e2e/pocketbase-harness.ts index 804eaff..0f5f24b 100644 --- a/e2e/pocketbase-harness.ts +++ b/e2e/pocketbase-harness.ts @@ -83,19 +83,52 @@ async function waitForReady(url: string, timeoutMs = 30000): Promise { } /** - * Creates the admin superuser using the PocketBase CLI. + * Sleeps for the specified number of milliseconds. */ -function createAdminUser( +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Creates the admin superuser using the PocketBase CLI. + * Retries on database lock errors since PocketBase may still be running migrations. + */ +async function createAdminUser( dataDir: string, email: string, password: string, -): void { - execSync( - `pocketbase superuser upsert ${email} ${password} --dir=${dataDir}`, - { - stdio: "pipe", - }, - ); + maxRetries = 5, +): Promise { + let lastError: Error | null = null; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + execSync( + `pocketbase superuser upsert ${email} ${password} --dir=${dataDir}`, + { + stdio: "pipe", + }, + ); + return; + } catch (err) { + lastError = err as Error; + const errorMsg = String(lastError.message || lastError); + + // Retry on database lock errors + if ( + errorMsg.includes("database is locked") || + errorMsg.includes("SQLITE_BUSY") + ) { + await sleep(100 * (attempt + 1)); // Exponential backoff: 100ms, 200ms, 300ms... + continue; + } + + // For other errors, throw immediately + throw err; + } + } + + throw lastError; } /** @@ -186,6 +219,41 @@ async function setupCollections(pb: PocketBase): Promise { await setupApiRules(pb); } +/** + * Retries an async operation with exponential backoff. + */ +async function retryAsync( + operation: () => Promise, + maxRetries = 5, + baseDelayMs = 100, +): Promise { + let lastError: Error | null = null; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + return await operation(); + } catch (err) { + lastError = err as Error; + const errorMsg = String(lastError.message || lastError); + + // Retry on transient errors (database busy, connection issues) + if ( + errorMsg.includes("database is locked") || + errorMsg.includes("SQLITE_BUSY") || + errorMsg.includes("Failed to create record") + ) { + await sleep(baseDelayMs * (attempt + 1)); + continue; + } + + // For other errors, throw immediately + throw err; + } + } + + throw lastError; +} + /** * Creates the test user with period data. */ @@ -199,24 +267,28 @@ async function createTestUser( fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14); const lastPeriodDate = fourteenDaysAgo.toISOString().split("T")[0]; - // Create the test user - const user = await pb.collection("users").create({ - email, - password, - passwordConfirm: password, - emailVisibility: true, - verified: true, - lastPeriodDate, - cycleLength: 28, - notificationTime: "07:00", - timezone: "UTC", - }); + // Create the test user (with retry for transient errors) + const user = await retryAsync(() => + pb.collection("users").create({ + email, + password, + passwordConfirm: password, + emailVisibility: true, + verified: true, + lastPeriodDate, + cycleLength: 28, + notificationTime: "07:00", + timezone: "UTC", + }), + ); - // Create a period log entry - await pb.collection("period_logs").create({ - user: user.id, - startDate: lastPeriodDate, - }); + // Create a period log entry (with retry for transient errors) + await retryAsync(() => + pb.collection("period_logs").create({ + user: user.id, + startDate: lastPeriodDate, + }), + ); return user.id; } @@ -254,8 +326,8 @@ export async function start( // Wait for PocketBase to be ready await waitForReady(url); - // Create admin user via CLI - createAdminUser(dataDir, config.adminEmail, config.adminPassword); + // Create admin user via CLI (with retry for database lock during migrations) + await createAdminUser(dataDir, config.adminEmail, config.adminPassword); // Connect to PocketBase as admin const pb = new PocketBase(url); diff --git a/scripts/setup-db.test.ts b/scripts/setup-db.test.ts index 3d41a9f..8776bed 100644 --- a/scripts/setup-db.test.ts +++ b/scripts/setup-db.test.ts @@ -7,6 +7,7 @@ import { getExistingCollectionNames, getMissingCollections, PERIOD_LOGS_COLLECTION, + USER_CUSTOM_FIELDS, } from "./setup-db"; describe("PERIOD_LOGS_COLLECTION", () => { @@ -162,3 +163,21 @@ describe("getMissingCollections", () => { expect(missing).toHaveLength(0); }); }); + +describe("USER_CUSTOM_FIELDS garmin token max lengths", () => { + it("should have sufficient max length for garminOauth2Token field", () => { + const oauth2Field = USER_CUSTOM_FIELDS.find( + (f) => f.name === "garminOauth2Token", + ); + expect(oauth2Field).toBeDefined(); + expect(oauth2Field?.max).toBeGreaterThanOrEqual(10000); + }); + + it("should have sufficient max length for garminOauth1Token field", () => { + const oauth1Field = USER_CUSTOM_FIELDS.find( + (f) => f.name === "garminOauth1Token", + ); + expect(oauth1Field).toBeDefined(); + expect(oauth1Field?.max).toBeGreaterThanOrEqual(10000); + }); +}); diff --git a/scripts/setup-db.ts b/scripts/setup-db.ts index 8761bb9..b455fdc 100644 --- a/scripts/setup-db.ts +++ b/scripts/setup-db.ts @@ -6,10 +6,12 @@ import PocketBase from "pocketbase"; * Collection field definition for PocketBase. * For relation fields, collectionId/maxSelect/cascadeDelete are top-level properties. */ -interface CollectionField { +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; @@ -142,10 +144,10 @@ 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. */ -const USER_CUSTOM_FIELDS = [ +export const USER_CUSTOM_FIELDS: CollectionField[] = [ { name: "garminConnected", type: "bool" }, - { name: "garminOauth1Token", type: "text" }, - { name: "garminOauth2Token", type: "text" }, + { name: "garminOauth1Token", type: "text", max: 20000 }, + { name: "garminOauth2Token", type: "text", max: 20000 }, { name: "garminTokenExpiresAt", type: "date" }, { name: "calendarToken", type: "text" }, { name: "lastPeriodDate", type: "date" }, @@ -156,36 +158,62 @@ const USER_CUSTOM_FIELDS = [ ]; /** - * Adds custom fields to the users collection if they don't already exist. + * 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 { const usersCollection = await pb.collections.getOne("users"); - // Get existing field names - const existingFieldNames = new Set( - (usersCollection.fields || []).map((f: { name: string }) => f.name), + // Build a map of existing fields by name + const existingFieldsMap = new Map>( + (usersCollection.fields || []).map((f: Record) => [ + f.name as string, + f, + ]), ); - // Filter to only new fields - const newFields = USER_CUSTOM_FIELDS.filter( - (f) => !existingFieldNames.has(f.name), - ); + // Separate new fields from fields that need updating + const newFields: CollectionField[] = []; + const fieldsToUpdate: string[] = []; - if (newFields.length > 0) { - // Combine existing fields with new ones + 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, }); - console.log( - ` Added ${newFields.length} field(s) to users:`, - newFields.map((f) => f.name), - ); + 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."); + console.log(" All user fields already exist with correct settings."); } }