diff --git a/internal/db/diff/pgdelta.go b/internal/db/diff/pgdelta.go index 70b30a95a1..8ba8fb0fe9 100644 --- a/internal/db/diff/pgdelta.go +++ b/internal/db/diff/pgdelta.go @@ -6,6 +6,7 @@ import ( _ "embed" "encoding/json" "os" + "path/filepath" "strings" "github.com/go-errors/errors" @@ -47,12 +48,14 @@ func isPostgresURL(ref string) bool { // containerRef translates a host-relative catalog file path into the absolute // path where it appears inside the edge runtime container (CWD mounted at -// /workspace). Postgres URLs and empty strings pass through unchanged. +// /workspace). Postgres URLs and empty strings pass through unchanged. Path +// separators are normalised to forward slashes so Windows paths (with `\`) +// resolve correctly inside the Linux container. func containerRef(ref string) string { if ref == "" || isPostgresURL(ref) { return ref } - return "/workspace/" + ref + return "/workspace/" + filepath.ToSlash(ref) } // pgDeltaFormatOptions returns the experimental.pgdelta.format_options config for @@ -143,6 +146,9 @@ func DeclarativeExportPgDeltaRef(ctx context.Context, sourceRef, targetRef strin if err := utils.RunEdgeRuntimeScript(ctx, env, pgDeltaDeclarativeExportScript, binds, "error exporting declarative schema", &stdout, &stderr); err != nil { return DeclarativeOutput{}, err } + if stdout.Len() == 0 { + return DeclarativeOutput{}, errors.Errorf("error exporting declarative schema: edge-runtime script produced no output:\n%s", stderr.String()) + } var result DeclarativeOutput if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { return DeclarativeOutput{}, errors.Errorf("failed to parse declarative export output: %w", err) @@ -176,5 +182,9 @@ func ExportCatalogPgDelta(ctx context.Context, targetRef, role string, options . if err := utils.RunEdgeRuntimeScript(ctx, env, pgDeltaCatalogExportScript, binds, "error exporting pg-delta catalog", &stdout, &stderr); err != nil { return "", err } - return strings.TrimSpace(stdout.String()), nil + snapshot := strings.TrimSpace(stdout.String()) + if len(snapshot) == 0 { + return "", errors.Errorf("error exporting pg-delta catalog: edge-runtime script produced no output:\n%s", stderr.String()) + } + return snapshot, nil } diff --git a/internal/db/diff/pgdelta_test.go b/internal/db/diff/pgdelta_test.go new file mode 100644 index 0000000000..671414a069 --- /dev/null +++ b/internal/db/diff/pgdelta_test.go @@ -0,0 +1,34 @@ +package diff + +import ( + "runtime" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestContainerRef(t *testing.T) { + t.Run("passes empty string through", func(t *testing.T) { + assert.Equal(t, "", containerRef("")) + }) + + t.Run("passes postgres URLs through", func(t *testing.T) { + assert.Equal(t, "postgresql://user@host:5432/db", containerRef("postgresql://user@host:5432/db")) + assert.Equal(t, "postgres://user@host:5432/db", containerRef("postgres://user@host:5432/db")) + }) + + t.Run("normalises Windows path separators", func(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("path separator behaviour is Windows-only") + } + // On Windows, filepath.Join produces backslashes which the Linux + // container cannot read; containerRef must convert them. + ref := `supabase\.temp\pgdelta\catalog-baseline-17.6.1.106.json` + assert.Equal(t, "/workspace/supabase/.temp/pgdelta/catalog-baseline-17.6.1.106.json", containerRef(ref)) + }) + + t.Run("leaves unix paths untouched", func(t *testing.T) { + ref := "supabase/.temp/pgdelta/catalog-baseline-17.6.1.106.json" + assert.Equal(t, "/workspace/supabase/.temp/pgdelta/catalog-baseline-17.6.1.106.json", containerRef(ref)) + }) +} diff --git a/internal/db/pgcache/cache.go b/internal/db/pgcache/cache.go index db12199475..c6881c84cc 100644 --- a/internal/db/pgcache/cache.go +++ b/internal/db/pgcache/cache.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/go-errors/errors" "github.com/jackc/pgconn" "github.com/jackc/pgx/v4" "github.com/spf13/afero" @@ -255,5 +256,9 @@ func exportCatalog(ctx context.Context, targetRef string, options ...func(*pgx.C if err := utils.RunEdgeRuntimeScript(ctx, env, pgDeltaCatalogExportTS, binds, "error exporting pg-delta catalog", &stdout, &stderr); err != nil { return "", err } - return strings.TrimSpace(stdout.String()), nil + snapshot := strings.TrimSpace(stdout.String()) + if len(snapshot) == 0 { + return "", errors.Errorf("error exporting pg-delta catalog: edge-runtime script produced no output:\n%s", stderr.String()) + } + return snapshot, nil } diff --git a/internal/telemetry/state.go b/internal/telemetry/state.go index 58096c5de4..8d69996468 100644 --- a/internal/telemetry/state.go +++ b/internal/telemetry/state.go @@ -17,6 +17,12 @@ const SchemaVersion = 1 const sessionRotationThreshold = 30 * time.Minute +// errMalformedState marks any read where the file existed but couldn't be +// decoded into a State — covers JSON syntax errors, unexpected types +// (e.g. session_last_active stored as a number), and field-level unmarshal +// failures from time.Time / uuid. Used to trigger fresh-state creation. +var errMalformedState = errors.New("malformed telemetry state") + type State struct { Enabled bool `json:"enabled"` DeviceID string `json:"device_id"` @@ -48,7 +54,7 @@ func LoadState(fsys afero.Fs) (State, error) { } var state State if err := json.Unmarshal(contents, &state); err != nil { - return State{}, errors.Errorf("failed to parse telemetry file: %w", err) + return State{}, errors.Errorf("%w: %v", errMalformedState, err) } return state, nil } @@ -74,7 +80,11 @@ func LoadOrCreateState(fsys afero.Fs, now time.Time) (State, bool, error) { state.SessionLastActive = now.UTC() return state, false, SaveState(state, fsys) } - if !errors.Is(err, os.ErrNotExist) { + // Treat a missing file OR an unparseable file as "no existing state" and + // recreate. Identity fields (device_id, session_id) are not worth + // surfacing an error for — losing them is harmless. We only propagate + // genuine I/O errors (permissions, disk full) so the user can act. + if !errors.Is(err, os.ErrNotExist) && !errors.Is(err, errMalformedState) { return State{}, false, err } state = State{ diff --git a/internal/telemetry/state_test.go b/internal/telemetry/state_test.go index a2a07a5b35..9cd03dd967 100644 --- a/internal/telemetry/state_test.go +++ b/internal/telemetry/state_test.go @@ -79,6 +79,38 @@ func TestLoadOrCreateState(t *testing.T) { assert.Equal(t, now, state.SessionLastActive) }) + t.Run("recovers from corrupted state file", func(t *testing.T) { + // Each entry simulates a real-world corruption shape we've observed. + corruptions := map[string][]byte{ + "empty file": {}, + "truncated json": []byte(`{"enabled":tru`), + "session_last_active is a number (not a string)": []byte(`{"enabled":true,"device_id":"d","session_id":"s","session_last_active":1776770348993,"schema_version":1}`), + "session_last_active is a malformed string": []byte(`{"enabled":true,"device_id":"d","session_id":"s","session_last_active":"not-a-time","schema_version":1}`), + } + for label, contents := range corruptions { + t.Run(label, func(t *testing.T) { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + fsys := afero.NewMemMapFs() + path, err := telemetryPath() + require.NoError(t, err) + require.NoError(t, fsys.MkdirAll("/tmp/supabase-home", 0755)) + require.NoError(t, afero.WriteFile(fsys, path, contents, 0644)) + + state, created, err := LoadOrCreateState(fsys, now) + + require.NoError(t, err) + assert.True(t, created) + assert.True(t, state.Enabled) + assert.Equal(t, SchemaVersion, state.SchemaVersion) + assert.NoError(t, uuid.Validate(state.DeviceID)) + assert.NoError(t, uuid.Validate(state.SessionID)) + saved, err := LoadState(fsys) + require.NoError(t, err) + assert.Equal(t, state, saved) + }) + } + }) + t.Run("rotates stale session after inactivity threshold", func(t *testing.T) { t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") fsys := afero.NewMemMapFs()