From b91e87b3e19d46225e5867d6edd29fb9be2b47f6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 09:20:08 +0000 Subject: [PATCH 1/2] fix(desktop): wait for the server's database upgrade instead of reporting a failed start The desktop gave the backend a fixed readiness budget and then showed "could not start". Long upgrade migrations on large databases outlast it, and the server printed nothing while migrating, so the app reported a false failure and users quit mid-migration. The server now logs "Running database migrations" before pending migrations run. While that is the backend's latest migration line, the desktop keeps waiting instead of reporting a readiness failure. A crash still reports through the exit path. AI-assisted; verified with the tests and tools listed above. --- .../src/backend/DesktopBackendManager.test.ts | 36 +++++++++++++++++-- .../src/backend/DesktopBackendManager.ts | 7 ++++ apps/server/src/persistence/Migrations.ts | 8 ++++- packages/shared/package.json | 4 +++ packages/shared/src/databaseMigrationLog.ts | 11 ++++++ 5 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 packages/shared/src/databaseMigrationLog.ts diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 3126d12a2e938..71eaa4b235e2e 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -5,6 +5,10 @@ import { type DesktopBackendBootstrap as DesktopBackendBootstrapValue, DesktopTelemetryControlMessage, } from "@t3tools/contracts"; +import { + DATABASE_MIGRATIONS_FINISHED_MESSAGE, + DATABASE_MIGRATIONS_STARTED_MESSAGE, +} from "@t3tools/shared/databaseMigrationLog"; import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -688,12 +692,21 @@ describe("DesktopBackendManager", () => { Effect.scoped( Effect.gen(function* () { const reports = yield* Queue.unbounded(); - const startInstance = (status: Ref.Ref, onReady?: Effect.Effect) => + const startInstance = ( + status: Ref.Ref, + onReady?: Effect.Effect, + stdout = "", + ) => makeTestInstance({ spawnerLayer: Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(() => - Effect.succeed(makeProcess({ exitCode: Effect.never })), + Effect.succeed( + makeProcess({ + exitCode: Effect.never, + stdout: Stream.make(new TextEncoder().encode(stdout)), + }), + ), ), ), httpClientLayer: layerHttpClient((request) => @@ -712,7 +725,24 @@ describe("DesktopBackendManager", () => { yield* Deferred.await(ready); assert.isTrue(Option.isNone(yield* Queue.poll(reports))); - yield* startInstance(yield* Ref.make(503)); + const migratingStatus = yield* Ref.make(503); + const migrated = yield* Deferred.make(); + yield* startInstance( + migratingStatus, + Deferred.succeed(migrated, undefined).pipe(Effect.asVoid), + `INFO ${DATABASE_MIGRATIONS_STARTED_MESSAGE}\n`, + ); + yield* TestClock.adjust(Duration.minutes(10)); + assert.isTrue(Option.isNone(yield* Queue.poll(reports))); + yield* Ref.set(migratingStatus, 200); + yield* TestClock.adjust(Duration.millis(100)); + yield* Deferred.await(migrated); + + yield* startInstance( + yield* Ref.make(503), + undefined, + `${DATABASE_MIGRATIONS_STARTED_MESSAGE}\n${DATABASE_MIGRATIONS_FINISHED_MESSAGE}\n`, + ); yield* TestClock.adjust(Duration.seconds(179)); assert.isTrue(Option.isNone(yield* Queue.poll(reports))); yield* TestClock.adjust(Duration.seconds(1)); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index d24b2f52f295c..970ff2001d399 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -25,6 +25,7 @@ import { DesktopTelemetryControlMessage, type DesktopTelemetryControlMessage as DesktopTelemetryControlMessageValue, } from "@t3tools/contracts"; +import { isRunningDatabaseMigrations } from "@t3tools/shared/databaseMigrationLog"; import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/httpReadiness"; import * as DesktopObservability from "../app/DesktopObservability.ts"; @@ -971,6 +972,12 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( }), onReadinessFailure: Effect.fn("desktop.backendInstance.onReadinessFailure")( function* (error) { + if (isRunningDatabaseMigrations(yield* Ref.get(outputTail))) { + yield* logInstanceWarning("backend is still migrating its database; waiting", { + error: error.message, + }); + return; + } yield* logInstanceWarning("backend readiness check failed during bootstrap", { error: error.message, }); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 5e4784e26fc95..59b378714ae7c 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -1,6 +1,10 @@ import * as Migrator from "effect/sql/Migrator"; import * as Effect from "effect/Effect"; import * as SqlClient from "effect/sql/SqlClient"; +import { + DATABASE_MIGRATIONS_FINISHED_MESSAGE, + DATABASE_MIGRATIONS_STARTED_MESSAGE, +} from "@t3tools/shared/databaseMigrationLog"; import { reconcileV2PreviewMigration } from "./reconcileV2PreviewMigration.ts"; import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; @@ -256,6 +260,8 @@ export interface RunMigrationsOptions { export const runMigrations = Effect.fn("runMigrations")(function* ({ toMigrationInclusive, }: RunMigrationsOptions = {}) { + // The desktop waits past its readiness budget while this is the server's last output. + if (yield* hasPendingMigrations()) yield* Effect.log(DATABASE_MIGRATIONS_STARTED_MESSAGE); const includesV2 = toMigrationInclusive === undefined || toMigrationInclusive >= FIRST_V2_MIGRATION_ID; const previewMigrations = includesV2 ? yield* reconcileV2PreviewMigration() : []; @@ -269,7 +275,7 @@ export const runMigrations = Effect.fn("runMigrations")(function* ({ const migrations = executedMigrations.map(([id, name]) => `${id}_${name}`); yield* migrations.length === 0 ? Effect.logDebug("Database schema is current") - : Effect.log("Migrations ran successfully").pipe(Effect.annotateLogs({ migrations })); + : Effect.log(DATABASE_MIGRATIONS_FINISHED_MESSAGE).pipe(Effect.annotateLogs({ migrations })); const sql = yield* SqlClient.SqlClient; const recorded = yield* sql<{ diff --git a/packages/shared/package.json b/packages/shared/package.json index f5f769236f906..9df772f66d238 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -367,6 +367,10 @@ "types": "./src/testing/symlinks.ts", "import": "./src/testing/symlinks.ts" }, + "./databaseMigrationLog": { + "types": "./src/databaseMigrationLog.ts", + "import": "./src/databaseMigrationLog.ts" + }, "./httpReadiness": { "types": "./src/httpReadiness.ts", "import": "./src/httpReadiness.ts" diff --git a/packages/shared/src/databaseMigrationLog.ts b/packages/shared/src/databaseMigrationLog.ts new file mode 100644 index 0000000000000..f411a8872e130 --- /dev/null +++ b/packages/shared/src/databaseMigrationLog.ts @@ -0,0 +1,11 @@ +// The server logs these around pending migrations; the desktop reads them from its output. +// Migrations run synchronously, so a long one blocks the server from writing anything else. +export const DATABASE_MIGRATIONS_STARTED_MESSAGE = "Running database migrations"; +export const DATABASE_MIGRATIONS_FINISHED_MESSAGE = "Migrations ran successfully"; + +export function isRunningDatabaseMigrations(output: string): boolean { + return ( + output.lastIndexOf(DATABASE_MIGRATIONS_STARTED_MESSAGE) > + output.lastIndexOf(DATABASE_MIGRATIONS_FINISHED_MESSAGE) + ); +} From 954404df98b5485c8f90def1452d98983462faba Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 23:18:41 +0000 Subject: [PATCH 2/2] fix(desktop): keep waiting when the migration start line scrolls out of the output tail The server now logs one progress line per pending migration, and the desktop keeps only the last 4,000 characters of backend output. An upgrade spanning many migrations pushed the start line out, so the desktop reported a failed start again. Progress lines now count as a running upgrade too. Co-Authored-By: Claude Opus 5.5 --- .../desktop/src/backend/DesktopBackendManager.test.ts | 9 ++++++++- apps/server/src/persistence/Migrations.ts | 5 +++-- packages/shared/src/databaseMigrationLog.ts | 11 +++++++---- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 71eaa4b235e2e..e96e036b0c88b 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -6,6 +6,7 @@ import { DesktopTelemetryControlMessage, } from "@t3tools/contracts"; import { + DATABASE_MIGRATION_PROGRESS_MESSAGE, DATABASE_MIGRATIONS_FINISHED_MESSAGE, DATABASE_MIGRATIONS_STARTED_MESSAGE, } from "@t3tools/shared/databaseMigrationLog"; @@ -730,7 +731,13 @@ describe("DesktopBackendManager", () => { yield* startInstance( migratingStatus, Deferred.succeed(migrated, undefined).pipe(Effect.asVoid), - `INFO ${DATABASE_MIGRATIONS_STARTED_MESSAGE}\n`, + // Enough progress lines that the start line scrolls out of the output tail. + `INFO ${DATABASE_MIGRATIONS_STARTED_MESSAGE}\n` + + Array.from( + { length: 80 }, + (_, index) => + `INFO ${DATABASE_MIGRATION_PROGRESS_MESSAGE} migration=${index}_Migration progress="${index + 1} of 80"\n`, + ).join(""), ); yield* TestClock.adjust(Duration.minutes(10)); assert.isTrue(Option.isNone(yield* Queue.poll(reports))); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 88ee322d4c0ba..d89297bd7aeb0 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -2,6 +2,7 @@ import * as Migrator from "effect/sql/Migrator"; import * as Effect from "effect/Effect"; import * as SqlClient from "effect/sql/SqlClient"; import { + DATABASE_MIGRATION_PROGRESS_MESSAGE, DATABASE_MIGRATIONS_FINISHED_MESSAGE, DATABASE_MIGRATIONS_STARTED_MESSAGE, } from "@t3tools/shared/databaseMigrationLog"; @@ -266,7 +267,7 @@ export interface RunMigrationsOptions { export const runMigrations = Effect.fn("runMigrations")(function* ({ toMigrationInclusive, }: RunMigrationsOptions = {}) { - // The desktop waits past its readiness budget while this is the server's last output. + // The desktop waits past its readiness budget until the finished line follows this one. if (yield* hasPendingMigrations()) yield* Effect.log(DATABASE_MIGRATIONS_STARTED_MESSAGE); const includesV2 = toMigrationInclusive === undefined || toMigrationInclusive >= FIRST_V2_MIGRATION_ID; @@ -294,7 +295,7 @@ export const runMigrations = Effect.fn("runMigrations")(function* ({ const executedMigrations: Array = [...previewMigrations]; for (const [index, [id, name]] of pending.entries()) { if (latestRecordedId > 0) { - yield* Effect.log("Upgrading database").pipe( + yield* Effect.log(DATABASE_MIGRATION_PROGRESS_MESSAGE).pipe( Effect.annotateLogs({ migration: `${id}_${name}`, progress: `${index + 1} of ${pending.length}`, diff --git a/packages/shared/src/databaseMigrationLog.ts b/packages/shared/src/databaseMigrationLog.ts index f411a8872e130..7adc4e4a6c84c 100644 --- a/packages/shared/src/databaseMigrationLog.ts +++ b/packages/shared/src/databaseMigrationLog.ts @@ -1,11 +1,14 @@ -// The server logs these around pending migrations; the desktop reads them from its output. -// Migrations run synchronously, so a long one blocks the server from writing anything else. +// The server logs these around pending migrations; the desktop reads them from its output tail, +// which keeps only the last few thousand characters, so each progress line also marks a running upgrade. export const DATABASE_MIGRATIONS_STARTED_MESSAGE = "Running database migrations"; +export const DATABASE_MIGRATION_PROGRESS_MESSAGE = "Upgrading database"; export const DATABASE_MIGRATIONS_FINISHED_MESSAGE = "Migrations ran successfully"; export function isRunningDatabaseMigrations(output: string): boolean { return ( - output.lastIndexOf(DATABASE_MIGRATIONS_STARTED_MESSAGE) > - output.lastIndexOf(DATABASE_MIGRATIONS_FINISHED_MESSAGE) + Math.max( + output.lastIndexOf(DATABASE_MIGRATIONS_STARTED_MESSAGE), + output.lastIndexOf(DATABASE_MIGRATION_PROGRESS_MESSAGE), + ) > output.lastIndexOf(DATABASE_MIGRATIONS_FINISHED_MESSAGE) ); }